From 41ab7b346cf5d126c3ea7f9a911ea9b6092e3f76 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:08:58 +0200 Subject: [PATCH 0001/1118] Set timeout for remote calendar (#147024) --- .../components/remote_calendar/client.py | 12 ++++++++++++ .../components/remote_calendar/config_flow.py | 12 +++++++++--- .../components/remote_calendar/coordinator.py | 15 +++++++++++---- .../components/remote_calendar/strings.json | 6 +++++- .../remote_calendar/test_config_flow.py | 12 +++++++----- tests/components/remote_calendar/test_init.py | 7 ++++--- 6 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/remote_calendar/client.py diff --git a/homeassistant/components/remote_calendar/client.py b/homeassistant/components/remote_calendar/client.py new file mode 100644 index 00000000000..f0f243ca386 --- /dev/null +++ b/homeassistant/components/remote_calendar/client.py @@ -0,0 +1,12 @@ +"""Specifies the parameter for the httpx download.""" + +from httpx import AsyncClient, Response, Timeout + + +async def get_calendar(client: AsyncClient, url: str) -> Response: + """Make an HTTP GET request using Home Assistant's async HTTPX client with timeout.""" + return await client.get( + url, + follow_redirects=True, + timeout=Timeout(5, read=30, write=5, pool=5), + ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 558a3d668ae..3f835b5d82b 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -4,13 +4,14 @@ from http import HTTPStatus import logging from typing import Any -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.helpers.httpx_client import get_async_client +from .client import get_calendar from .const import CONF_CALENDAR_NAME, DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -49,7 +50,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) client = get_async_client(self.hass) try: - res = await client.get(user_input[CONF_URL], follow_redirects=True) + res = await get_calendar(client, user_input[CONF_URL]) if res.status_code == HTTPStatus.FORBIDDEN: errors["base"] = "forbidden" return self.async_show_form( @@ -58,9 +59,14 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) res.raise_for_status() + except TimeoutException as err: + errors["base"] = "timeout_connect" + _LOGGER.debug( + "A timeout error occurred: %s", str(err) or type(err).__name__ + ) except (HTTPError, InvalidURL) as err: errors["base"] = "cannot_connect" - _LOGGER.debug("An error occurred: %s", err) + _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__) else: try: await parse_calendar(self.hass, res.text) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 1eead7682d3..26876b53224 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException from ical.calendar import Calendar from homeassistant.config_entries import ConfigEntry @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .client import get_calendar from .const import DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -36,7 +37,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"{DOMAIN}_{config_entry.title}", update_interval=SCAN_INTERVAL, always_update=True, ) @@ -46,13 +47,19 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): async def _async_update_data(self) -> Calendar: """Update data from the url.""" try: - res = await self._client.get(self._url, follow_redirects=True) + res = await get_calendar(self._client, self._url) res.raise_for_status() + except TimeoutException as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout", + ) from err except (HTTPError, InvalidURL) as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) raise UpdateFailed( translation_domain=DOMAIN, translation_key="unable_to_fetch", - translation_placeholders={"err": str(err)}, ) from err try: self.ics = res.text diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index ef7f20d4699..48ef6080bdb 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -18,14 +18,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { + "timeout": { + "message": "The connection timed out. See the debug log for additional details." + }, "unable_to_fetch": { - "message": "Unable to fetch calendar data: {err}" + "message": "Unable to fetch calendar data. See the debug log for additional details." }, "unable_to_parse": { "message": "Unable to parse calendar data: {err}" diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 9aff1594db3..9bea46ab27e 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Remote Calendar config flow.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -75,10 +75,11 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None @pytest.mark.parametrize( - ("side_effect"), + ("side_effect", "base_error"), [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + (TimeoutException("Connection timed out"), "timeout_connect"), + (HTTPError("Connection failed"), "cannot_connect"), + (InvalidURL("Unsupported protocol"), "cannot_connect"), ], ) @respx.mock @@ -86,6 +87,7 @@ async def test_form_inavild_url( hass: HomeAssistant, side_effect: Exception, ics_content: str, + base_error: str, ) -> None: """Test we get the import form.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +104,7 @@ async def test_form_inavild_url( }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": base_error} respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index f4ca500b2e1..d3e6b439805 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -1,6 +1,6 @@ """Tests for init platform of Remote Calendar.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -56,8 +56,9 @@ async def test_raise_for_status( @pytest.mark.parametrize( "side_effect", [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + TimeoutException("Connection timed out"), + HTTPError("Connection failed"), + InvalidURL("Unsupported protocol"), ValueError("Invalid response"), ], ) From 9a4959560e0330d2523f074679a16dfb2ab55d3b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:51:38 +0200 Subject: [PATCH 0002/1118] Fix missing port in samsungtv (#147962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/samsungtv/config_flow.py | 25 +++++++++++-------- .../components/samsungtv/test_config_flow.py | 17 +++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index dbde1ee1ef3..e2b9f8631d8 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -124,6 +124,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._model: str | None = None self._connect_result: str | None = None self._method: str | None = None + self._port: int | None = None self._name: str | None = None self._title: str = "" self._id: int | None = None @@ -199,33 +200,37 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_create_bridge(self) -> None: """Create the bridge.""" - result, method, _info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: LOGGER.debug("No working config found for %s", self._host) raise AbortFlow(result) - assert method is not None - self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) + assert self._method is not None + self._bridge = SamsungTVBridge.get_bridge( + self.hass, self._method, self._host, self._port + ) - async def _async_get_device_info_and_method( + async def _async_load_device_info( self, - ) -> tuple[str, str | None, dict[str, Any] | None]: + ) -> str: """Get device info and method only once.""" if self._connect_result is None: - result, _, method, info = await async_get_device_info(self.hass, self._host) + result, port, method, info = await async_get_device_info( + self.hass, self._host + ) self._connect_result = result self._method = method + self._port = port self._device_info = info if not method: LOGGER.debug("Host:%s did not return device info", self._host) - return result, None, None - return self._connect_result, self._method, self._device_info + return self._connect_result async def _async_get_and_check_device_info(self) -> bool: """Try to get the device info.""" - result, _method, info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: raise AbortFlow(result) - if not info: + if not (info := self._device_info): return False dev_info = info.get("device", {}) assert dev_info is not None diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index d63e5a7ae2a..dd6b21ab5e5 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -161,6 +161,7 @@ async def test_user_legacy(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result["data"][CONF_MODEL] is None + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id is None @@ -195,6 +196,7 @@ async def test_user_legacy_does_not_ok_first_time(hass: HomeAssistant) -> None: assert result3["data"][CONF_METHOD] == METHOD_LEGACY assert result3["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result3["data"][CONF_MODEL] is None + assert result3["data"][CONF_PORT] == 55000 assert result3["result"].unique_id is None @@ -224,6 +226,7 @@ async def test_user_websocket(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -272,6 +275,7 @@ async def test_user_encrypted_websocket( assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] is None assert result4["data"][CONF_TOKEN] == "037739871315caef138547b03e348b72" assert result4["data"][CONF_SESSION_ID] == "1" @@ -402,6 +406,7 @@ async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -464,6 +469,7 @@ async def test_ssdp(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -522,6 +528,7 @@ async def test_ssdp_noprefix(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -557,6 +564,7 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -599,6 +607,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -630,6 +639,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_loc assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" @@ -681,6 +691,7 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert ( result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -887,6 +898,7 @@ async def test_dhcp_wireless(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE48JU6400" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "223da676-497a-4e06-9507-5e27ec4f0fb3" @@ -919,6 +931,7 @@ async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: assert result["data"][CONF_MAC] == "aa:ee:tt:hh:ee:rr" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE43LS003" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1020,6 +1033,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1129,6 +1143,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1180,6 +1195,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" assert result["data"][CONF_MAC] == "gg:ee:tt:mm:aa:cc" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -2091,6 +2107,7 @@ async def test_ssdp_update_mac(hass: HomeAssistant) -> None: assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert entry.data[CONF_MODEL] == "fake_model" assert entry.data[CONF_MAC] is None + assert entry.data[CONF_PORT] == 8002 assert entry.unique_id == "123" device_info = deepcopy(MOCK_DEVICE_INFO) From 0f32b6331d8928a5ed814eb39955e0181bcaebe9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:19:32 -0400 Subject: [PATCH 0003/1118] Bump ZHA to 0.0.62 (#147966) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 51 ------------------- 4 files changed, 3 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4fb5f57320f..2cbc962a305 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.61"], + "requirements": ["zha==0.0.62"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 04b3f0eaa2f..4a4454e9dab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3190,7 +3190,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.61 +zha==0.0.62 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea925412def..c40ea6e17fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2634,7 +2634,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.61 +zha==0.0.62 # homeassistant.components.zwave_js zwave-js-server-python==0.65.0 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 44fb913489d..35eb320893f 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -168,7 +168,6 @@ dict({ 'id': '0x0010', 'name': 'cie_addr', - 'unsupported': False, 'value': list([ 50, 79, @@ -181,68 +180,18 @@ ]), 'zcl_type': 'EUI64', }), - dict({ - 'id': '0x0013', - 'name': 'current_zone_sensitivity_level', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), dict({ 'id': '0x0012', 'name': 'num_zone_sensitivity_levels_supported', 'unsupported': True, - 'value': None, 'zcl_type': 'uint8', }), - dict({ - 'id': '0x0011', - 'name': 'zone_id', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), - dict({ - 'id': '0x0000', - 'name': 'zone_state', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), - dict({ - 'id': '0x0002', - 'name': 'zone_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'map16', - }), - dict({ - 'id': '0x0001', - 'name': 'zone_type', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), ]), 'cluster_id': '0x0500', 'endpoint_attribute': 'ias_zone', }), dict({ 'attributes': list([ - dict({ - 'id': '0xfffd', - 'name': 'cluster_revision', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), - dict({ - 'id': '0xfffe', - 'name': 'reporting_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), ]), 'cluster_id': '0x0501', 'endpoint_attribute': 'ias_ace', From a2ffe32b025e3ecf2150c48f409d609f9bc22ecd Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 2 Jul 2025 23:10:21 +0200 Subject: [PATCH 0004/1118] Bump aiounifi to v84 (#147987) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index dd255c57c13..d13b180d62d 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==83"], + "requirements": ["aiounifi==84"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4a4454e9dab..2fece9385aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==84 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c40ea6e17fc..6118a3ede0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -396,7 +396,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==84 # homeassistant.components.usb aiousbwatcher==1.1.1 From c23bfb1b39d083ce2fae27f0545d7061b8608736 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 3 Jul 2025 08:50:41 +0200 Subject: [PATCH 0005/1118] Fix state being incorrectly reported in some situations on Music Assistant players (#147997) --- homeassistant/components/music_assistant/manifest.json | 2 +- homeassistant/components/music_assistant/media_player.py | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index e29491e2b21..4b28a1029a4 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.3"], + "requirements": ["music-assistant-client==1.2.4"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index b748aad241c..3a210856391 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -248,8 +248,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): player = self.player active_queue = self.active_queue # update generic attributes - if player.powered and active_queue is not None: - self._attr_state = MediaPlayerState(active_queue.state.value) if player.powered and player.playback_state is not None: self._attr_state = MediaPlayerState(player.playback_state.value) else: diff --git a/requirements_all.txt b/requirements_all.txt index 2fece9385aa..e67b8687e5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.3 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6118a3ede0b..ca114781c23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1259,7 +1259,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.3 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 From f806e6ba49687105249baed3a242d74adbd29eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 3 Jul 2025 13:24:51 +0100 Subject: [PATCH 0006/1118] Bump hass-nabucasa from 0.104.0 to 0.105.0 (#148040) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 70cf6a2c072..0d44d57ac5e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.104.0"], + "requirements": ["hass-nabucasa==0.105.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f0fa428fbb3..e666f06c5f2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.0 diff --git a/pyproject.toml b/pyproject.toml index c34a268e347..77d788a1dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.104.0", + "hass-nabucasa==0.105.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 1791d12268b..011d76e66b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e67b8687e5d..dd663763a54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca114781c23..f624e1d552b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 898ef43750b1c7bd2e304f01f635e2603b1d61ee Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 4 Jul 2025 22:32:46 +0800 Subject: [PATCH 0007/1118] Fix Telegram bots using plain text parser failing to load on restart (#148050) --- homeassistant/components/telegram_bot/bot.py | 6 +++--- homeassistant/components/telegram_bot/config_flow.py | 2 -- homeassistant/components/telegram_bot/services.yaml | 5 +++++ tests/components/telegram_bot/test_config_flow.py | 2 +- tests/components/telegram_bot/test_telegram_bot.py | 5 ++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index a3feb120460..c57648c9551 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -374,9 +374,7 @@ class TelegramNotificationService: } if data is not None: if ATTR_PARSER in data: - params[ATTR_PARSER] = self._parsers.get( - data[ATTR_PARSER], self.parse_mode - ) + params[ATTR_PARSER] = data[ATTR_PARSER] if ATTR_TIMEOUT in data: params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT] if ATTR_DISABLE_NOTIF in data: @@ -408,6 +406,8 @@ class TelegramNotificationService: params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup( [_make_row_inline_keyboard(row) for row in keys] ) + if params[ATTR_PARSER] == PARSER_PLAIN_TEXT: + params[ATTR_PARSER] = None return params async def _send_msg( diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 41f26ccd48d..8d3d9b0cd7b 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -159,8 +159,6 @@ class OptionsFlowHandler(OptionsFlow): """Manage the options.""" if user_input is not None: - if user_input[ATTR_PARSER] == PARSER_PLAIN_TEXT: - user_input[ATTR_PARSER] = None return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d5fc0e134d5..b1d94d381ac 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -109,6 +109,7 @@ send_photo: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -261,6 +262,7 @@ send_animation: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -341,6 +343,7 @@ send_video: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -493,6 +496,7 @@ send_document: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -670,6 +674,7 @@ edit_message: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_web_page_preview: selector: boolean: diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2586761b584..9a076016a32 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -63,7 +63,7 @@ async def test_options_flow( await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][ATTR_PARSER] is None + assert result["data"][ATTR_PARSER] == PARSER_PLAIN_TEXT async def test_reconfigure_flow_broadcast( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 6590bbed1cf..73dd9e27763 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -50,6 +50,7 @@ from homeassistant.components.telegram_bot.const import ( ATTR_VERIFY_SSL, CONF_CONFIG_ENTRY_ID, DOMAIN, + PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, @@ -183,6 +184,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link", }, InlineKeyboardMarkup( @@ -199,6 +201,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: [ [["command1", "/cmd1"]], [["mock_link", "https://mock_link"]], @@ -250,7 +253,7 @@ async def test_send_message_with_inline_keyboard( mock_send_message.assert_called_once_with( 12345678, "test_message", - parse_mode=ParseMode.MARKDOWN, + parse_mode=None, disable_web_page_preview=None, disable_notification=False, reply_to_message_id=None, From f42e7d982f78d2c4ee99fa813d14d600f1a4cf40 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:04:13 +0200 Subject: [PATCH 0008/1118] Bump pyenphase to 2.2.0 (#148070) --- 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 5f74da954a0..8387ecc9c9f 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.1.0"], + "requirements": ["pyenphase==2.2.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index dd663763a54..fb548289de5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f624e1d552b..e3b58a5dfe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.everlights pyeverlights==0.1.0 From eb58c10e5ee87d0d0098c868de2acba3a4284ac0 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 22:06:38 +0200 Subject: [PATCH 0009/1118] Cancel enphase mac verification on unload. (#148072) --- homeassistant/components/enphase_envoy/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index eee6cb85e6d..f43d89aa098 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() coordinator.async_cancel_firmware_refresh() + coordinator.async_cancel_mac_verification() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 342b4c344249340a9f3f44538d164325e6cb6dea Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 4 Jul 2025 17:33:16 +0300 Subject: [PATCH 0010/1118] Bump aioamazondevices to 3.2.3 (#148082) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 7c23edd92ce..70281390436 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.2.2"] + "requirements": ["aioamazondevices==3.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index fb548289de5..cae697cdfe9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.2 +aioamazondevices==3.2.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3b58a5dfe8..de092158c7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.2 +aioamazondevices==3.2.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From c646658643a8d2e056d229498b0e70385f90a11d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 4 Jul 2025 16:25:28 +0200 Subject: [PATCH 0011/1118] Update frontend to 20250702.1 (#148131) --- 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 bfd868a5334..748d8f0c6f0 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==20250702.0"] + "requirements": ["home-assistant-frontend==20250702.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e666f06c5f2..824c3d945fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.105.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index cae697cdfe9..04ed2500e01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de092158c7e..fede2337353 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 3ffec2a655f74ac805d32f78164a14cad25678b3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:22:38 +0200 Subject: [PATCH 0012/1118] [ci] Fix typing issue with aiohttp and aiosignal (#148141) --- .github/workflows/ci.yaml | 2 +- homeassistant/components/http/ban.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19cc8bd3af7..c5491df0021 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 3 + CACHE_VERSION: 4 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.7" diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 71f3d54bef6..7e55191639b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) + app.on_startup.append(ban_startup) # type: ignore[arg-type] @middleware From 4e163c4591529f09b90f1fb4d299a00663fac563 Mon Sep 17 00:00:00 2001 From: Michael Freeman Date: Fri, 4 Jul 2025 11:46:59 -0400 Subject: [PATCH 0013/1118] Bump venstarcolortouch to 0.21 (#148152) --- homeassistant/components/venstar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index f3045fe49e8..5991dc8fe51 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.19"] + "requirements": ["venstarcolortouch==0.21"] } diff --git a/requirements_all.txt b/requirements_all.txt index 04ed2500e01..fcd97e305fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3041,7 +3041,7 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fede2337353..a234822689c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2509,7 +2509,7 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 From a274961593b41bd2a2aa950427a437da55914b4e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Jul 2025 19:22:41 +0000 Subject: [PATCH 0014/1118] Bump version to 2025.7.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f1a9f7f79c2..258a9c9a48f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 77d788a1dd0..b4478a69d53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0" +version = "2025.7.1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 91b82621287aba9fa7bb7561ecd390dda3c9945b Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 8 Jul 2025 20:48:44 +0800 Subject: [PATCH 0015/1118] Update strings for Telegram bot (#148409) --- homeassistant/components/telegram_bot/strings.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 8ef71022492..df3de556efb 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -220,15 +220,15 @@ }, "username": { "name": "[%key:common::config_flow::data::username%]", - "description": "Username for a URL which requires HTTP `basic` or `digest` authentication." + "description": "Username for a URL that requires 'Basic' or 'Digest' authentication." }, "password": { "name": "[%key:common::config_flow::data::password%]", - "description": "Password (or bearer token) for a URL which require authentication." + "description": "Password (or bearer token) for a URL that requires authentication." }, "authentication": { "name": "Authentication method", - "description": "Define which authentication method to use. Set to `basic` for HTTP basic authentication, `digest` for HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication." + "description": "Define which authentication method to use. Set to 'Basic' for HTTP basic authentication, 'Digest' for HTTP digest authentication, or 'Bearer token' for OAuth 2.0 bearer token authentication." }, "target": { "name": "Target", @@ -950,10 +950,6 @@ "deprecated_yaml_import_issue_error": { "title": "YAML import failed due to invalid {error_field}", "description": "Configuring {integration_title} using YAML is being removed but there was an error while importing your existing configuration ({telegram_bot}): {error_message}.\nSetup will not proceed.\n\nVerify that your {telegram_bot} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "proxy_params_auth_deprecation": { - "title": "{telegram_bot}: Proxy authentication should be moved to the URL", - "description": "Authentication details for the the proxy configured in the {telegram_bot} integration should be moved into the {proxy_url} instead. Please update your configuration and restart Home Assistant to fix this issue.\n\nThe {proxy_params} config key will be removed in a future release." } } } From 420d1e169dd669805f800f9e0d76389e746cd8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 8 Jul 2025 13:49:09 +0100 Subject: [PATCH 0016/1118] Fix hassfest command in copilot-instructions (#148405) --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c2b863b55be..603cf407081 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1149,7 +1149,7 @@ _LOGGER.debug("Processing data: %s", data) # Use lazy logging ### Validation Commands ```bash # Check specific integration -python -m script.hassfest --integration my_integration +python -m script.hassfest --integration-path homeassistant/components/my_integration # Validate quality scale # Check quality_scale.yaml against current rules From 77ae6048ef885aeced7945347b82078070578822 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:49:52 +0200 Subject: [PATCH 0017/1118] Add tuya snapshot tests for gas leak sensor (#148400) --- tests/components/tuya/__init__.py | 5 ++ .../tuya/fixtures/rqbj_gas_sensor.json | 90 +++++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++++++++ .../tuya/snapshots/test_sensor.ambr | 52 +++++++++++ 4 files changed, 196 insertions(+) create mode 100644 tests/components/tuya/fixtures/rqbj_gas_sensor.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 61c559ecffe..1dacd799744 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,11 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "rqbj_gas_sensor": [ + # https://github.com/orgs/home-assistant/discussions/100 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], "sfkzq_valve_controller": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/rqbj_gas_sensor.json b/tests/components/tuya/fixtures/rqbj_gas_sensor.json new file mode 100644 index 00000000000..58cbaedb0f1 --- /dev/null +++ b/tests/components/tuya/fixtures/rqbj_gas_sensor.json @@ -0,0 +1,90 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "17421891051898r7yM6", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "ebb9d0eb5014f98cfboxbz", + "name": "Gas sensor", + "category": "rqbj", + "product_id": "4iqe2hsfyd86kwwc", + "product_name": "Gas sensor", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-24T20:33:10+00:00", + "create_time": "2025-06-24T20:33:10+00:00", + "update_time": "2025-06-24T20:33:10+00:00", + "function": { + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "checking_result": { + "type": "Enum", + "value": { + "range": ["checking", "check_success", "check_failure", "others"] + } + }, + "gas_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "gas_sensor_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "checking_result": "check_success", + "gas_sensor_status": "normal", + "alarm_time": 300, + "gas_sensor_value": 0, + "self_checking": false, + "muffling": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index aacda463769..b269664a2d4 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -48,3 +48,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-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.gas_sensor_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'context': , + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 562f34cc8b9..ac34dc615b7 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -581,3 +581,55 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-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.gas_sensor_gas', + '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': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas', + 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- From 8ccd097e987103e8936cae9df82e75fbfe32df8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:50:49 +0200 Subject: [PATCH 0018/1118] Add tuya snapshot tests for bladeless tower fan (#148401) --- tests/components/tuya/__init__.py | 6 ++ .../tuya/fixtures/kj_bladeless_tower_fan.json | 79 +++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 57 +++++++++++++ .../tuya/snapshots/test_select.ambr | 65 +++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++++ 5 files changed, 255 insertions(+) create mode 100644 tests/components/tuya/fixtures/kj_bladeless_tower_fan.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1dacd799744..5f9c8ef86c6 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -35,6 +35,12 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "kj_bladeless_tower_fan": [ + # https://github.com/orgs/home-assistant/discussions/61 + Platform.FAN, + Platform.SELECT, + Platform.SWITCH, + ], "mcs_door_sensor": [ # https://github.com/home-assistant/core/issues/108301 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json new file mode 100644 index 00000000000..8cbe875718e --- /dev/null +++ b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "CENSORED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "CENSORED", + "name": "Bree", + "category": "kj", + "product_id": "CENSORED", + "product_name": "40\" Bladeless Tower Fan", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-22T07:35:33+00:00", + "create_time": "2025-06-22T07:35:33+00:00", + "update_time": "2025-06-22T07:35:33+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "switch": false, + "mode": "normal", + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 399056e7665..cbd3c997625 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -49,3 +49,60 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bree', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.CENSORED', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree', + 'preset_mode': 'normal', + 'preset_modes': list([ + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index b9e11f5b50a..519ac33fb9f 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -60,6 +60,71 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bree_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.CENSOREDcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'context': , + 'entity_id': 'select.bree_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 8f03c6d7313..c4e813ddfdc 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-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.bree_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.CENSOREDswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Power', + }), + 'context': , + 'entity_id': 'switch.bree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 546f6afac25a77bfe90f7e7c2de5faf918f4b35d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Jul 2025 16:11:15 +0200 Subject: [PATCH 0019/1118] Bump gios to version 6.1.1 (#148414) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index ba87890de03..1782320a357 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.0"] + "requirements": ["gios==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a45a1f31b83..11c2f3d3787 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 476a2f9e6fe..eb03b87f5cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.glances glances-api==0.8.0 From ae7bc140596e9a4cc1239def1bcf55b6432000ae Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Jul 2025 16:14:02 +0200 Subject: [PATCH 0020/1118] Make the update interval a property of the NextDNS coordinator class (#148410) --- homeassistant/components/nextdns/__init__.py | 26 +++++++------------ .../components/nextdns/coordinator.py | 25 +++++++++++++++--- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index eb8bd26cb9b..acc9504988d 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import timedelta from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -37,9 +36,6 @@ from .const import ( ATTR_STATUS, CONF_PROFILE_ID, DOMAIN, - UPDATE_INTERVAL_ANALYTICS, - UPDATE_INTERVAL_CONNECTION, - UPDATE_INTERVAL_SETTINGS, ) from .coordinator import ( NextDnsConnectionUpdateCoordinator, @@ -69,14 +65,14 @@ class NextDnsData: PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator], timedelta]] = [ - (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator, UPDATE_INTERVAL_CONNECTION), - (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator, UPDATE_INTERVAL_SETTINGS), - (ATTR_STATUS, NextDnsStatusUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), +COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ + (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), + (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator), + (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator), + (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator), + (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator), + (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator), + (ATTR_STATUS, NextDnsStatusUpdateCoordinator), ] @@ -109,10 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. - for coordinator_name, coordinator_class, update_interval in COORDINATORS: - coordinator = coordinator_class( - hass, entry, nextdns, profile_id, update_interval - ) + for coordinator_name, coordinator_class in COORDINATORS: + coordinator = coordinator_class(hass, entry, nextdns, profile_id) tasks.append(coordinator.async_config_entry_first_refresh()) coordinators[coordinator_name] = coordinator diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 9b82e82ffe0..44470fe0070 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -29,7 +29,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import NextDnsConfigEntry -from .const import DOMAIN +from .const import ( + DOMAIN, + UPDATE_INTERVAL_ANALYTICS, + UPDATE_INTERVAL_CONNECTION, + UPDATE_INTERVAL_SETTINGS, +) _LOGGER = logging.getLogger(__name__) @@ -40,6 +45,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( """Class to manage fetching NextDNS data API.""" config_entry: NextDnsConfigEntry + _update_interval: timedelta def __init__( self, @@ -47,7 +53,6 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, - update_interval: timedelta, ) -> None: """Initialize.""" self.nextdns = nextdns @@ -58,7 +63,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=self._update_interval, ) async def _async_update_data(self) -> CoordinatorDataT: @@ -93,6 +98,8 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): """Class to manage fetching NextDNS analytics status data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsStatus: """Update data via library.""" return await self.nextdns.get_analytics_status(self.profile_id) @@ -101,6 +108,8 @@ class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): """Class to manage fetching NextDNS analytics Dnssec data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsDnssec: """Update data via library.""" return await self.nextdns.get_analytics_dnssec(self.profile_id) @@ -109,6 +118,8 @@ class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): """Class to manage fetching NextDNS analytics encryption data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsEncryption: """Update data via library.""" return await self.nextdns.get_analytics_encryption(self.profile_id) @@ -117,6 +128,8 @@ class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncry class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): """Class to manage fetching NextDNS analytics IP versions data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsIpVersions: """Update data via library.""" return await self.nextdns.get_analytics_ip_versions(self.profile_id) @@ -125,6 +138,8 @@ class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVer class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): """Class to manage fetching NextDNS analytics protocols data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsProtocols: """Update data via library.""" return await self.nextdns.get_analytics_protocols(self.profile_id) @@ -133,6 +148,8 @@ class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtoc class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_SETTINGS + async def _async_update_data_internal(self) -> Settings: """Update data via library.""" return await self.nextdns.get_settings(self.profile_id) @@ -141,6 +158,8 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_CONNECTION + async def _async_update_data_internal(self) -> ConnectionStatus: """Update data via library.""" return await self.nextdns.connection_status(self.profile_id) From aab8908af8b84b6d60cf3926f6345d3a41c544fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 8 Jul 2025 16:24:06 +0200 Subject: [PATCH 0021/1118] Improve entity registry tests related to config entries in devices (#148399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- tests/helpers/test_entity_registry.py | 88 +++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 714dfed32e9..5afffebb5f6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1640,6 +1640,8 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry(domain="some_helper") + config_entry_3.add_to_hass(hass) # Create device with two config entries device_registry.async_get_or_create( @@ -1662,8 +1664,18 @@ async def test_remove_config_entry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity with a config entry not in the device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_3, + device_id=device_entry.id, + ) + assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) # Remove the first config entry from the device device_registry.async_update_device( @@ -1673,6 +1685,19 @@ async def test_remove_config_entry_from_device_removes_entities_2( assert device_registry.async_get(device_entry.id) assert entity_registry.async_is_registered(entry_1.entity_id) + # Entities with a config entry not in the device are removed + assert not entity_registry.async_is_registered(entry_2.entity_id) + + # Remove the second config entry from the device + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry_2.entry_id + ) + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + # The device is removed, both entities are now removed + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -1797,10 +1822,19 @@ async def test_remove_config_subentry_from_device_removes_entities( assert not entity_registry.async_is_registered(entry_3.entity_id) +@pytest.mark.parametrize( + ("subentries_in_device", "subentry_in_entity"), + [ + (["mock-subentry-id-1", "mock-subentry-id-2"], None), + ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), + ], +) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + subentries_in_device: list[str | None], + subentry_in_entity: str | None, ) -> None: """Test that we don't remove entities with no config entry when device is modified.""" config_entry_1 = MockConfigEntry( @@ -1820,28 +1854,31 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-3", + subentry_type="test", + title="Mock title", + unique_id="test", + ), ], ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries + # Create device with two config subentries device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", + config_subentry_id=subentries_in_device[0], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id=subentries_in_device[1], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert device_entry.config_entries == {config_entry_1.entry_id} assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + config_entry_1.entry_id: set(subentries_in_device), } # Create an entity without config entry or subentry @@ -1851,30 +1888,57 @@ async def test_remove_config_subentry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity for same config entry but subentry not in device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_1, + config_subentry_id=subentry_in_entity, + device_id=device_entry.id, + ) + # Create an entity for same config entry but subentry not in device + entry_3 = entity_registry.async_get_or_create( + "light", + "some_helper", + "abcd", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-3", + device_id=device_entry.id, + ) + assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) # Remove the first config subentry from the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, + remove_config_subentry_id=subentries_in_device[0], ) await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) assert entity_registry.async_is_registered(entry_1.entity_id) + # Entities with a config subentry not in the device are removed + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) # Remove the second config subentry from the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1", + remove_config_subentry_id=subentries_in_device[1], ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry.id) + # All entities are now removed + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) async def test_update_device_race( From c97ad9657f586e21afaab0a0c5c18ef1b6cf9fbc Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Tue, 8 Jul 2025 08:58:32 -0600 Subject: [PATCH 0022/1118] Add metadata support to Snapcast media players (#132283) Co-authored-by: Joostlek --- .../components/snapcast/media_player.py | 74 +++++ tests/components/snapcast/__init__.py | 12 + tests/components/snapcast/conftest.py | 158 +++++++++- tests/components/snapcast/const.py | 4 + .../snapcast/snapshots/test_media_player.ambr | 271 ++++++++++++++++++ tests/components/snapcast/test_config_flow.py | 20 +- .../components/snapcast/test_media_player.py | 30 ++ 7 files changed, 550 insertions(+), 19 deletions(-) create mode 100644 tests/components/snapcast/const.py create mode 100644 tests/components/snapcast/snapshots/test_media_player.ambr create mode 100644 tests/components/snapcast/test_media_player.py diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 7d9cf74b2cc..8e3f787e71d 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -12,9 +12,11 @@ import voluptuous as vol from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -180,6 +182,8 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_media_content_type = MediaType.MUSIC + _attr_device_class = MediaPlayerDeviceClass.SPEAKER def __init__( self, @@ -275,6 +279,76 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Handle the unjoin service.""" raise NotImplementedError + @property + def metadata(self) -> Mapping[str, Any]: + """Get metadata from the current stream.""" + if metadata := self.coordinator.server.stream( + self._current_group.stream + ).metadata: + return metadata + + # Fallback to an empty dict + return {} + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self.metadata.get("title") + + @property + def media_image_url(self) -> str | None: + """Image url of current playing media.""" + return self.metadata.get("artUrl") + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if (value := self.metadata.get("artist")) is not None: + return ", ".join(value) + + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self.metadata.get("album") + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if (value := self.metadata.get("albumArtist")) is not None: + return ", ".join(value) + + return None + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if (value := self.metadata.get("trackNumber")) is not None: + return int(value) + + return None + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if (value := self.metadata.get("duration")) is not None: + return int(value) + + return None + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + # Position is part of properties object, not metadata object + if properties := self.coordinator.server.stream( + self._current_group.stream + ).properties: + if (value := properties.get("position")) is not None: + return int(value) + + return None + class SnapcastGroupDevice(SnapcastBaseDevice): """Representation of a Snapcast group device.""" diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py index a325bd41bd7..69bf252f53a 100644 --- a/tests/components/snapcast/__init__.py +++ b/tests/components/snapcast/__init__.py @@ -1 +1,13 @@ """Tests for the Snapcast integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Snapcast integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index bcc0ac5bc30..9c8a0bc5668 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -1,9 +1,19 @@ """Test the snapcast config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup +from snapcast.control.server import CONTROL_PORT +from snapcast.control.stream import Snapstream + +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.components.snapcast.coordinator import Snapserver +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry @pytest.fixture @@ -16,10 +26,144 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_create_server() -> Generator[AsyncMock]: +def mock_create_server( + mock_group: AsyncMock, + mock_client: AsyncMock, + mock_stream_1: AsyncMock, + mock_stream_2: AsyncMock, +) -> Generator[AsyncMock]: """Create mock snapcast connection.""" - mock_connection = AsyncMock() - mock_connection.start = AsyncMock(return_value=None) - mock_connection.stop = MagicMock() - with patch("snapcast.control.create_server", return_value=mock_connection): - yield mock_connection + with patch( + "homeassistant.components.snapcast.coordinator.Snapserver", autospec=True + ) as mock_snapserver: + mock_server = mock_snapserver.return_value + mock_server.groups = [mock_group] + mock_server.clients = [mock_client] + mock_server.streams = [mock_stream_1, mock_stream_2] + mock_server.group.return_value = mock_group + mock_server.client.return_value = mock_client + + def get_stream(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.streams}[identifier] + + mock_server.stream = get_stream + yield mock_server + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + + # Create a mock config entry + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: CONTROL_PORT, + }, + ) + + +@pytest.fixture +def mock_server_connection() -> Generator[Snapserver]: + """Create a mock server connection.""" + + # Patch the start method of the Snapserver class to avoid network connections + with patch.object(Snapserver, "start", new_callable=AsyncMock) as mock_start: + yield mock_start + + +@pytest.fixture +def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1" + group.name = "test_group" + group.friendly_name = "test_group" + group.stream = stream + group.muted = False + group.stream_status = streams[stream].status + group.volume = 48 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_client(mock_group: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#2" + client.friendly_name = "test_client" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient" + client.latency = 6 + client.muted = False + client.volume = 48 + client.group = mock_group + mock_group.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_stream_1() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_1" + stream.status = "playing" + stream.name = "Test Stream 1" + stream.friendly_name = "Test Stream 1" + stream.metadata = { + "album": "Test Album", + "artist": ["Test Artist 1", "Test Artist 2"], + "title": "Test Title", + "artUrl": "http://localhost/test_art.jpg", + "albumArtist": [ + "Test Album Artist 1", + "Test Album Artist 2", + ], + "trackNumber": 10, + "duration": 60.0, + } + stream.meta = stream.metadata + stream.properties = { + "position": 30.0, + **stream.metadata, + } + stream.path = None + return stream + + +@pytest.fixture +def mock_stream_2() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_2" + stream.status = "idle" + stream.name = "Test Stream 2" + stream.friendly_name = "Test Stream 2" + stream.metadata = None + stream.meta = None + stream.properties = None + stream.path = None + return stream + + +@pytest.fixture( + params=[ + "test_stream_1", + "test_stream_2", + ] +) +def stream(request: pytest.FixtureRequest) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]: + """Return a dictionary of mock streams.""" + return { + mock_stream_1.identifier: mock_stream_1, + mock_stream_2.identifier: mock_stream_2, + } diff --git a/tests/components/snapcast/const.py b/tests/components/snapcast/const.py new file mode 100644 index 00000000000..0fbd5a05460 --- /dev/null +++ b/tests/components/snapcast/const.py @@ -0,0 +1,4 @@ +"""Constants for Snapcast tests.""" + +TEST_CLIENT_ENTITY_ID = "media_player.test_client_snapcast_client" +TEST_GROUP_ENTITY_ID = "media_player.test_group_snapcast_group" diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..c497cdd861b --- /dev/null +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -0,0 +1,271 @@ +# serializer version: 1 +# name: test_state[test_stream_1][media_player.test_client_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_1][media_player.test_client_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_client_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_client Snapcast Client', + 'is_volume_muted': False, + 'latency': 6, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_client_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[test_stream_1][media_player.test_group_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_group Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_1][media_player.test_group_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_group_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_group Snapcast Group', + 'is_volume_muted': False, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_group_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[test_stream_2][media_player.test_client_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_2][media_player.test_client_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_client Snapcast Client', + 'is_volume_muted': False, + 'latency': 6, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_client_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[test_stream_2][media_player.test_group_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_group Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_2][media_player.test_group_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_group Snapcast Group', + 'is_volume_muted': False, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_group_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index 3bdba8b4c58..50ab4f0c170 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -15,12 +15,10 @@ from tests.common import MockConfigEntry TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock -) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form and handle errors and successful connection.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -55,21 +53,19 @@ async def test_form( assert result["errors"] == {"base": "cannot_connect"} # test success - result = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_CONNECTION - ) - await hass.async_block_till_done() + with patch("snapcast.control.create_server"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Snapcast" assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - assert len(mock_create_server.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock -) -> None: +async def test_abort(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test config flow abort if device is already configured.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/snapcast/test_media_player.py b/tests/components/snapcast/test_media_player.py new file mode 100644 index 00000000000..57a8a865ddf --- /dev/null +++ b/tests/components/snapcast/test_media_player.py @@ -0,0 +1,30 @@ +"""Test the snapcast media player implementation.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test basic state information.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From a35299d94ce3ad790fe4c435e041de78549f20de Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:04:06 -0400 Subject: [PATCH 0023/1118] Add preview tests for number and sensor (#148426) --- tests/components/template/conftest.py | 44 ++++++++++++++++++++++++ tests/components/template/test_number.py | 24 ++++++++++++- tests/components/template/test_sensor.py | 19 ++++++++++ tests/components/template/test_switch.py | 38 ++++---------------- 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index c69c9e9e9a4..6d1776f24cd 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -4,11 +4,15 @@ from enum import Enum import pytest +from homeassistant.components import template +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.conftest import WebSocketGenerator class ConfigurationStyle(Enum): @@ -51,3 +55,43 @@ async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" + + +async def async_get_flow_preview_state( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + user_input: ConfigType, +) -> ConfigType: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": domain}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == domain + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + return msg["event"] diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index a15ae1e46c0..21dea28b73f 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -35,9 +35,10 @@ 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 .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.typing import WebSocketGenerator _TEST_OBJECT_ID = "template_number" _TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" @@ -608,3 +609,24 @@ async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: state = hass.states.get(_TEST_NUMBER) assert float(state.state) == 4 + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + number.DOMAIN, + { + "name": "My template", + "min": 0.0, + "max": 100.0, + **TEST_REQUIRED, + }, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index eb4f6c3596b..e89e98601d6 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -30,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util +from .conftest import async_get_flow_preview_state + from tests.common import ( MockConfigEntry, assert_setup_component, @@ -37,6 +39,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache_with_extra_data, ) +from tests.conftest import WebSocketGenerator TEST_NAME = "sensor.test_template_sensor" @@ -2434,3 +2437,19 @@ async def test_device_id( template_entity = entity_registry.async_get("sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + sensor.DOMAIN, + {"name": "My template", "state": "{{ 0.0 }}"}, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index de6894c73a8..c6ed303af7b 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -8,7 +8,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -18,12 +17,11 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import ( MockConfigEntry, @@ -396,37 +394,15 @@ async def test_flow_preview( hass_ws_client: WebSocketGenerator, ) -> None: """Test the config flow preview.""" - client = await hass_ws_client(hass) - result = await hass.config_entries.flow.async_init( - template.DOMAIN, context={"source": SOURCE_USER} + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + switch.DOMAIN, + {"name": "My template", state_key: "{{ 'on' }}"}, ) - assert result["type"] is FlowResultType.MENU - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": SWITCH_DOMAIN}, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == SWITCH_DOMAIN - assert result["errors"] is None - assert result["preview"] == "template" - - await client.send_json_auto_id( - { - "type": "template/start_preview", - "flow_id": result["flow_id"], - "flow_type": "config_flow", - "user_input": {"name": "My template", state_key: "{{ 'on' }}"}, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None - - msg = await client.receive_json() - assert msg["event"]["state"] == "on" + assert state["state"] == STATE_ON @pytest.mark.parametrize( From 6e63c17b396a6d7ed024fa2a6da581445ef8853a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 18:58:48 +0300 Subject: [PATCH 0024/1118] Improve exceptions in Alexa Devices (#148260) --- .../components/alexa_devices/config_flow.py | 11 ++++++++++- .../components/alexa_devices/coordinator.py | 14 ++++++++++++-- .../components/alexa_devices/strings.json | 5 +++-- homeassistant/components/alexa_devices/utils.py | 4 ++-- tests/components/alexa_devices/test_config_flow.py | 9 ++++++++- tests/components/alexa_devices/test_utils.py | 4 ++-- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index aa9bbb4ae5e..5ee3bc2e5f0 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -6,7 +6,12 @@ from collections.abc import Mapping from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,6 +62,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" except WrongCountry: errors["base"] = "wrong_country" else: @@ -106,6 +113,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" else: return self.async_update_reload_and_abort( reauth_entry, diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 031f52abebf..7af66f4bb8b 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -52,8 +52,18 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): try: await self.api.login_mode_stored_data() return await self.api.get_devices_data() - except (CannotConnect, CannotRetrieveData) as err: - raise UpdateFailed(f"Error occurred while updating {self.name}") from err + except CannotConnect as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err except CannotAuthenticate as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 03a6cc3de64..19cc39cab42 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -43,6 +43,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -84,10 +85,10 @@ } }, "exceptions": { - "cannot_connect": { + "cannot_connect_with_error": { "message": "Error connecting: {error}" }, - "cannot_retrieve_data": { + "cannot_retrieve_data_with_error": { "message": "Error retrieving data: {error}" } } diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index 4d1365d1d41..437b681413b 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -26,14 +26,14 @@ def alexa_api_call[_T: AmazonEntity, **_P]( self.coordinator.last_update_success = False raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect", + translation_key="cannot_connect_with_error", translation_placeholders={"error": repr(err)}, ) from err except CannotRetrieveData as err: self.coordinator.last_update_success = False raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_retrieve_data", + translation_key="cannot_retrieve_data_with_error", translation_placeholders={"error": repr(err)}, ) from err diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index def3a6ec547..e1b2974184b 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +62,7 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), (WrongCountry, "wrong_country"), ], ) @@ -165,6 +171,7 @@ async def test_reauth_successful( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), ], ) async def test_reauth_not_successful( diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 12009719a2f..1cf190bd297 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -21,8 +21,8 @@ ENTITY_ID = "switch.echo_test_do_not_disturb" @pytest.mark.parametrize( ("side_effect", "key", "error"), [ - (CannotConnect, "cannot_connect", "CannotConnect()"), - (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + (CannotConnect, "cannot_connect_with_error", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data_with_error", "CannotRetrieveData()"), ], ) async def test_alexa_api_call_exceptions( From ab1e323d49f1ce744a0f4a63aa1d1a915e98b706 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 18:44:11 +0200 Subject: [PATCH 0025/1118] Fix spelling of "non-volatile memory" in `z-wave_js` (#148422) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 5029e8c6108..63dad248246 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,7 +15,7 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", + "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the non-volatile memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", From ebffaed0bd1346d5cea4f89260b8b608f520c71e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 18:45:39 +0200 Subject: [PATCH 0026/1118] Fix spelling of "non-resettable" in `iskra` (#148417) --- homeassistant/components/iskra/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json index da7817cc78b..ee62974c90d 100644 --- a/homeassistant/components/iskra/strings.json +++ b/homeassistant/components/iskra/strings.json @@ -88,16 +88,16 @@ "name": "Phase 3 current" }, "non_resettable_counter_1": { - "name": "Non Resettable counter 1" + "name": "Non-resettable counter 1" }, "non_resettable_counter_2": { - "name": "Non Resettable counter 2" + "name": "Non-resettable counter 2" }, "non_resettable_counter_3": { - "name": "Non Resettable counter 3" + "name": "Non-resettable counter 3" }, "non_resettable_counter_4": { - "name": "Non Resettable counter 4" + "name": "Non-resettable counter 4" }, "resettable_counter_1": { "name": "Resettable counter 1" From 70c01efe570c3dbdec4952e5da5b529c465a5e1b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 19:58:35 +0300 Subject: [PATCH 0027/1118] Update Alexa Devices quality scale to silver (#148435) --- homeassistant/components/alexa_devices/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 34fdd1448a5..41154d91779 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aioamazondevices==3.2.8"] } From ed8effa1623efeb77c48fe2cd7d0d3459dba4612 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 23:58:39 +0200 Subject: [PATCH 0028/1118] Fix spelling of "non-existent", "non-blocking" and "currently used" (#148440) --- homeassistant/components/homeassistant/strings.json | 6 +++--- tests/test_core.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7c95680076c..77c29e7c495 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -40,7 +40,7 @@ }, "python_version": { "title": "Support for Python {current_python_version} is being removed", - "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + "description": "Support for running Home Assistant in the currently used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, "config_entry_only": { "title": "The {domain} integration does not support YAML configuration", @@ -81,7 +81,7 @@ "title": "Integration {domain} not found", "fix_flow": { "abort": { - "issue_ignored": "Not existing integration {domain} ignored." + "issue_ignored": "Non-existent integration {domain} ignored." }, "step": { "init": { @@ -274,7 +274,7 @@ "message": "Failed to process the returned action response data, expected a dictionary, but got {response_data_type}." }, "service_should_be_blocking": { - "message": "A non blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." + "message": "A non-blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." } } } diff --git a/tests/test_core.py b/tests/test_core.py index d4b5933aebe..0daaafe74cf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1847,7 +1847,7 @@ async def test_services_call_return_response_requires_blocking( return_response=True, ) assert str(exc.value) == ( - "A non blocking action call with argument blocking=False " + "A non-blocking action call with argument blocking=False " "can't be used together with argument return_response=True" ) From 6b5b35feceee75c57ea29630819c7d00445b0819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Jul 2025 22:34:35 -0600 Subject: [PATCH 0029/1118] Bump aioesphomeapi to 34.2.0 (#148456) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 01e04df6db8..9099af63ad9 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==34.1.0", + "aioesphomeapi==34.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 11c2f3d3787..1b14f4b4b2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==34.1.0 +aioesphomeapi==34.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb03b87f5cc..06b78118f78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==34.1.0 +aioesphomeapi==34.2.0 # homeassistant.components.flo aioflo==2021.11.0 From afcd9912622d40187de3622169c3102d66de95cd Mon Sep 17 00:00:00 2001 From: Oliver Heesakkers <10373284+OliverHe@users.noreply.github.com> Date: Wed, 9 Jul 2025 08:01:54 +0200 Subject: [PATCH 0030/1118] Handle processing errors when writing to Zabbix (#148449) --- homeassistant/components/zabbix/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 31a09242a71..432b5d50c4e 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -13,7 +13,7 @@ from urllib.parse import urljoin import voluptuous as vol from zabbix_utils import ItemValue, Sender, ZabbixAPI -from zabbix_utils.exceptions import APIRequestError +from zabbix_utils.exceptions import APIRequestError, ProcessingError from homeassistant.const import ( CONF_HOST, @@ -282,6 +282,8 @@ class ZabbixThread(threading.Thread): if not self.write_errors: _LOGGER.error("Write error: %s", err) self.write_errors += len(metrics) + except ProcessingError as prerr: + _LOGGER.error("Error writing to Zabbix: %s", prerr) def run(self) -> None: """Process incoming events.""" From a02359b25ddf96ed8544f56f8bb0af26bb2440ab Mon Sep 17 00:00:00 2001 From: Rico Hageman Date: Wed, 9 Jul 2025 09:28:55 +0200 Subject: [PATCH 0031/1118] Add dew point to Awair integration (#148403) --- homeassistant/components/awair/const.py | 1 + homeassistant/components/awair/sensor.py | 10 ++++++++++ homeassistant/components/awair/strings.json | 3 +++ 3 files changed, 14 insertions(+) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index a1c5781e9a4..10f7cb115da 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -6,6 +6,7 @@ from datetime import timedelta import logging API_CO2 = "carbon_dioxide" +API_DEW_POINT = "dew_point" API_DUST = "dust" API_HUMID = "humidity" API_LUX = "illuminance" diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index a0c4b5ba8fe..d1f3ec34bf4 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -34,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( API_CO2, + API_DEW_POINT, API_DUST, API_HUMID, API_LUX, @@ -110,6 +111,15 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( unique_id_tag="CO2", # matches legacy format state_class=SensorStateClass.MEASUREMENT, ), + AwairSensorEntityDescription( + key=API_DEW_POINT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="dew_point", + unique_id_tag="dew_point", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ) SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index a7c5c647af8..30425d2e1bc 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -57,6 +57,9 @@ }, "sound_level": { "name": "Sound level" + }, + "dew_point": { + "name": "Dew point" } } } From 6de630ef3e18edc1ca7d466817eae4f5f7ae2034 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Jul 2025 09:43:22 +0200 Subject: [PATCH 0032/1118] Fix sentence-casing of trigger subtypes in `xiaomi_ble` (#148463) --- .../components/xiaomi_ble/strings.json | 18 +++++++++--------- tests/components/xiaomi_ble/test_sensor.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 06b49b8e86f..ffdd8f29a79 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -59,13 +59,13 @@ "device_automation": { "trigger_subtype": { "press": "Press", - "double_press": "Double Press", - "long_press": "Long Press", - "motion_detected": "Motion Detected", - "rotate_left": "Rotate Left", - "rotate_right": "Rotate Right", - "rotate_left_pressed": "Rotate Left (Pressed)", - "rotate_right_pressed": "Rotate Right (Pressed)", + "double_press": "Double press", + "long_press": "Long press", + "motion_detected": "Motion detected", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "rotate_left_pressed": "Rotate left (pressed)", + "rotate_right_pressed": "Rotate right (pressed)", "match_successful": "Match successful", "match_failed": "Match failed", "low_quality_too_light_fuzzy": "Low quality (too light, fuzzy)", @@ -224,7 +224,7 @@ "state_attributes": { "event_type": { "state": { - "motion_detected": "Motion Detected" + "motion_detected": "Motion detected" } } } @@ -235,7 +235,7 @@ "name": "Impedance" }, "weight_non_stabilized": { - "name": "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 f5625d4e74d..3540c92682b 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -700,7 +700,7 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Weight 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" @@ -742,7 +742,7 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Weight 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" From cb2095bcbe26bac13627e4d73401a36d19e76013 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Wed, 9 Jul 2025 17:43:29 +1000 Subject: [PATCH 0033/1118] Bump aiolifx to 1.2.1 (#148464) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 3c03cdccba2..3c755779846 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -52,7 +52,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.2.0", + "aiolifx==1.2.1", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/requirements_all.txt b/requirements_all.txt index 1b14f4b4b2d..6e949c7a7f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -301,7 +301,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.2.0 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06b78118f78..820f681a841 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -283,7 +283,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.2.0 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 From 13d05a338bf717f8e3cd1804b7a99ff421b86dc9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:42:55 +0200 Subject: [PATCH 0034/1118] Sort tuya definitions by category (#148472) --- .../components/tuya/binary_sensor.py | 94 +- homeassistant/components/tuya/button.py | 16 +- homeassistant/components/tuya/camera.py | 6 +- homeassistant/components/tuya/climate.py | 9 +- homeassistant/components/tuya/cover.py | 50 +- homeassistant/components/tuya/fan.py | 2 +- homeassistant/components/tuya/light.py | 54 +- homeassistant/components/tuya/number.py | 126 +- homeassistant/components/tuya/select.py | 314 ++--- homeassistant/components/tuya/sensor.py | 1115 ++++++++--------- homeassistant/components/tuya/siren.py | 16 +- homeassistant/components/tuya/switch.py | 332 ++--- 12 files changed, 1068 insertions(+), 1066 deletions(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 486dd6e1387..a613661149f 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -46,6 +46,40 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( # end up being a binary sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO2_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="1", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATUS, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaBinarySensorEntityDescription( + key=DPCode.FEED_STATE, + translation_key="feeding", + on_value="feeding", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -111,40 +145,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO2_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="1", - ), - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATUS, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( - TuyaBinarySensorEntityDescription( - key=DPCode.FEED_STATE, - translation_key="feeding", - on_value="feeding", - ), - ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -174,6 +174,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TAMPER_BINARY_SENSOR, + ), # Door and Window Controller # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 "mc": ( @@ -205,16 +215,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { on_value={"AQAB"}, ), ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( - TuyaBinarySensorEntityDescription( - key=DPCode.TEMPER_ALARM, - device_class=BinarySensorDeviceClass.TAMPER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TAMPER_BINARY_SENSOR, - ), # PIR Detector # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 "pir": ( @@ -235,6 +235,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( @@ -291,9 +294,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 8e538b07309..928e584e77d 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -17,6 +17,14 @@ from .entity import TuyaEntity # All descriptions can be found here. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { + # Wake Up Light II + # Not documented + "hxd": ( + ButtonEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="snooze", + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -46,14 +54,6 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - ButtonEntityDescription( - key=DPCode.SWITCH_USB6, - translation_key="snooze", - ), - ), } diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index c04a8a043dc..788a9bcc5c3 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -17,12 +17,12 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq CAMERAS: tuple[str, ...] = ( - # Smart Camera (including doorbells) - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sp", # Smart Camera - Low power consumption camera # Undocumented, see https://github.com/home-assistant/core/issues/132844 "dghsxj", + # Smart Camera (including doorbells) + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sp", ) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 547f3a14c93..991c3589e12 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -47,6 +47,12 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { + # Electric Fireplace + # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + "dbl": TuyaClimateEntityDescription( + key="dbl", + switch_only_hvac_mode=HVACMode.HEAT, + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": TuyaClimateEntityDescription( @@ -77,9 +83,6 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), - # Electric Fireplace - # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop - "dbl": TuyaClimateEntityDescription(key="dbl", switch_only_hvac_mode=HVACMode.HEAT), } diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 315075e7f37..015daae4212 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -38,6 +38,31 @@ class TuyaCoverEntityDescription(CoverEntityDescription): COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + translation_key="door", + current_state=DPCode.DOORCONTACT_STATE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_2, + translation_key="door_2", + current_state=DPCode.DOORCONTACT_STATE_2, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_3, + translation_key="door_3", + current_state=DPCode.DOORCONTACT_STATE_3, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + ), # Curtain # Note: Multiple curtains isn't documented # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df @@ -84,31 +109,6 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.BLIND, ), ), - # Garage Door Opener - # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee - "ckmkzq": ( - TuyaCoverEntityDescription( - key=DPCode.SWITCH_1, - translation_key="door", - current_state=DPCode.DOORCONTACT_STATE, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_2, - translation_key="door_2", - current_state=DPCode.DOORCONTACT_STATE_2, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_3, - translation_key="door_3", - current_state=DPCode.DOORCONTACT_STATE_3, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - ), # Curtain Switch # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 "clkg": ( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 3b951e75da1..f2d856b6d86 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -25,11 +25,11 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_SUPPORT_TYPE = { + "cs", # Dehumidifier "fs", # Fan "fsd", # Fan with Light "fskg", # Fan wall switch "kj", # Air Purifier - "cs", # Dehumidifier } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 67a94c4e267..37c79b952d4 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -135,6 +135,22 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE, ), ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), # Ceiling Fan Light # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v "fsd": ( @@ -176,6 +192,17 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), + # Wake Up Light II + # Not documented + "hxd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + ), # Humidifier Light # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b "jsq": ( @@ -316,17 +343,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_2, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light", - brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), - brightness_max=DPCode.BRIGHTNESS_MAX_1, - brightness_min=DPCode.BRIGHTNESS_MIN_1, - ), - ), # Outdoor Flood Light # Not documented "tyd": ( @@ -378,22 +394,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_temp=DPCode.TEMP_CONTROLLER, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( - TuyaLightEntityDescription( - key=DPCode.LIGHT, - name=None, - color_mode=DPCode.WORK_MODE, - brightness=DPCode.BRIGHT_VALUE, - color_temp=DPCode.TEMP_VALUE, - ), - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light_2", - brightness=DPCode.BRIGHT_VALUE_1, - ), - ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d4fe7836daa..ddee46b8799 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -22,15 +22,6 @@ from .entity import IntegerTypeData, TuyaEntity # default instructions set of each category end up being a number. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="time", - entity_category=EntityCategory.CONFIG, - ), - ), # Smart Kettle # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 "bh": ( @@ -64,6 +55,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="alarm_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -76,6 +78,24 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { translation_key="voice_times", ), ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="time", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + NumberEntityDescription( + key=DPCode.TEMP, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -102,6 +122,20 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.DISTANCE, ), ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + NumberEntityDescription( + key=DPCode.TEMP_SET_F, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -174,6 +208,26 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Fingerbot + "szjqr": ( + NumberEntityDescription( + key=DPCode.ARM_DOWN_PERCENT, + translation_key="move_down", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ARM_UP_PERCENT, + translation_key="move_up", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLICK_SUSTAIN_TIME, + translation_key="down_delay", + entity_category=EntityCategory.CONFIG, + ), + ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( @@ -241,49 +295,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Fingerbot - "szjqr": ( - NumberEntityDescription( - key=DPCode.ARM_DOWN_PERCENT, - translation_key="move_down", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.ARM_UP_PERCENT, - translation_key="move_up", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.CLICK_SUSTAIN_TIME, - translation_key="down_delay", - entity_category=EntityCategory.CONFIG, - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( - NumberEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - NumberEntityDescription( - key=DPCode.TEMP_SET, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - NumberEntityDescription( - key=DPCode.TEMP_SET_F, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - ), # Pool HeatPump "znrb": ( NumberEntityDescription( @@ -292,17 +303,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="alarm_duration", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=NumberDeviceClass.DURATION, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 21f88156236..4ad4355f876 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -18,6 +18,43 @@ from .entity import TuyaEntity # default instructions set of each category end up being a select. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SelectEntityDescription( + key=DPCode.CONTROL_BACK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_motor_mode", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_mode", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.DEHUMIDITY_SET_ENUM, + translation_key="target_humidity", + entity_category=EntityCategory.CONFIG, + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -27,6 +64,81 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SelectEntityDescription( + key=DPCode.LEVEL, + name="Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_1, + name="Side A Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_2, + name="Side B Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + SelectEntityDescription( + key=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + translation_key="vertical_fan_angle", + ), + SelectEntityDescription( + key=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + translation_key="horizontal_fan_angle", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + SelectEntityDescription( + key=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_spray_mode", + ), + SelectEntityDescription( + key=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_level", + ), + SelectEntityDescription( + key=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_moodlighting", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -63,6 +175,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="light_mode", ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Heater # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm "qn": ( @@ -71,6 +197,25 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="temperature_level", ), ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SelectEntityDescription( + key=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_cistern", + ), + SelectEntityDescription( + key=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_collection", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_mode", + ), + ), # Smart Water Timer "sfkzq": ( # Irrigation will not be run within this set delay period @@ -128,6 +273,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Fingerbot + "szjqr": ( + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="fingerbot_mode", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -185,173 +338,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="led_type_2", ), ), - # Fingerbot - "szjqr": ( - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="fingerbot_mode", - ), - ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( - SelectEntityDescription( - key=DPCode.CISTERN, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_cistern", - ), - SelectEntityDescription( - key=DPCode.COLLECTION_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_collection", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_mode", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge - "fs": ( - SelectEntityDescription( - key=DPCode.FAN_VERTICAL, - entity_category=EntityCategory.CONFIG, - translation_key="vertical_fan_angle", - ), - SelectEntityDescription( - key=DPCode.FAN_HORIZONTAL, - entity_category=EntityCategory.CONFIG, - translation_key="horizontal_fan_angle", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SelectEntityDescription( - key=DPCode.CONTROL_BACK_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_motor_mode", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_mode", - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SelectEntityDescription( - key=DPCode.SPRAY_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_spray_mode", - ), - SelectEntityDescription( - key=DPCode.LEVEL, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_level", - ), - SelectEntityDescription( - key=DPCode.MOODLIGHTING, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_moodlighting", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.DEHUMIDITY_SET_ENUM, - translation_key="target_humidity", - entity_category=EntityCategory.CONFIG, - ), - ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SelectEntityDescription( - key=DPCode.ALARM_VOLUME, - translation_key="volume", - entity_category=EntityCategory.CONFIG, - ), - ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SelectEntityDescription( - key=DPCode.LEVEL, - name="Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_1, - name="Side A Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_2, - name="Side B Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - ), } # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SELECTS["cz"] = SELECTS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["pc"] = SELECTS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SELECTS["dghsxj"] = SELECTS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index bdfc8fe15e7..5151f39eb26 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -89,6 +89,169 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( # end up being a sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { + # Single Phase power meter + # Note: Undocumented + "aqcz": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + translation_key="status", + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + TuyaSensorEntityDescription( + key=DPCode.TIME_TOTAL, + translation_key="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaSensorEntityDescription( + key=DPCode.CO_VALUE, + translation_key="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_INDOOR, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_INDOOR, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaSensorEntityDescription( + key=DPCode.FEED_REPORT, + translation_key="last_amount", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Pet Fountain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln + "cwysj": ( + TuyaSensorEntityDescription( + key=DPCode.UV_RUNTIME, + translation_key="uv_runtime", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.PUMP_TIME, + translation_key="pump_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_DURATION, + translation_key="filter_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WATER_TIME, + translation_key="water_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.WATER_LEVEL, translation_key="water_level_state" + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -162,81 +325,92 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( + # Circuit Breaker + # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + "dlq": ( TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="current_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, + key=DPCode.TOTAL_FORWARD_ENERGY, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_F, - translation_key="current_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, + key=DPCode.CUR_NEUTRAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( - key=DPCode.STATUS, - translation_key="status", - ), - ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", ), TuyaSensorEntityDescription( - key=DPCode.CO2_VALUE, - translation_key="carbon_dioxide", - device_class=SensorDeviceClass.CO2, + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( - key=DPCode.CH2O_VALUE, - translation_key="formaldehyde", + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", ), TuyaSensorEntityDescription( - key=DPCode.VOC_VALUE, - translation_key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", ), TuyaSensorEntityDescription( - key=DPCode.PM25_VALUE, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, @@ -260,84 +434,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Single Phase power meter - # Note: Undocumented - "aqcz": ( + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( - TuyaSensorEntityDescription( - key=DPCode.CO_VALUE, - translation_key="carbon_monoxide", - device_class=SensorDeviceClass.CO, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( - TuyaSensorEntityDescription( - key=DPCode.FEED_REPORT, - translation_key="last_amount", + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln - "cwysj": ( - TuyaSensorEntityDescription( - key=DPCode.UV_RUNTIME, - translation_key="uv_runtime", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.PUMP_TIME, - translation_key="pump_time", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.FILTER_DURATION, - translation_key="filter_duration", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.WATER_TIME, - translation_key="water_time", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.WATER_LEVEL, translation_key="water_level_state" - ), - ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": BATTERY_SENSORS, # Air Quality Monitor # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv "hjjcy": ( @@ -428,6 +537,33 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LEVEL_CURRENT, + translation_key="water_level", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), # Methane Detector # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm "jwbj": ( @@ -463,61 +599,60 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # IoT Switch - # Note: Undocumented - "tdq": ( + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + key=DPCode.FILTER, + translation_key="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, + key=DPCode.PM25, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, + key=DPCode.TEMP, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, + key=DPCode.HUMIDITY, translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.TVOC, + translation_key="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, + key=DPCode.ECO2, + translation_key="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), - *BATTERY_SENSORS, + TuyaSensorEntityDescription( + key=DPCode.TOTAL_TIME, + translation_key="total_operating_time", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_PM, + translation_key="total_absorption_particles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.AIR_QUALITY, + translation_key="air_quality", + ), ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 @@ -642,6 +777,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( @@ -653,6 +829,55 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + TuyaSensorEntityDescription( + key=DPCode.CLEAN_AREA, + translation_key="cleaning_area", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CLEAN_TIME, + translation_key="cleaning_time", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_AREA, + translation_key="total_cleaning_area", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_TIME, + translation_key="total_cleaning_time", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_COUNT, + translation_key="total_cleaning_times", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.DUSTER_CLOTH, + translation_key="duster_cloth_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.EDGE_BRUSH, + translation_key="side_brush_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_LIFE, + translation_key="filter_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ROLL_BRUSH, + translation_key="rolling_brush_life", + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Smart Water Timer "sfkzq": ( # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) @@ -664,9 +889,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": BATTERY_SENSORS, # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, @@ -696,8 +918,80 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Gardening system + # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 + "sz": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Fingerbot "szjqr": BATTERY_SENSORS, + # IoT Switch + # Note: Undocumented + "tdq": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": BATTERY_SENSORS, @@ -741,9 +1035,87 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": BATTERY_SENSORS, + # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ), # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": ( @@ -779,48 +1151,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_EXTERNAL, - translation_key="temperature_external", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Pressure Sensor + # Wireless Switch + # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + "wxkg": BATTERY_SENSORS, # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( TuyaSensorEntityDescription( @@ -940,353 +1273,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { subkey="voltage", ), ), - # Circuit Breaker - # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 - "dlq": ( - TuyaSensorEntityDescription( - key=DPCode.TOTAL_FORWARD_ENERGY, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_NEUTRAL, - translation_key="total_production", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( - TuyaSensorEntityDescription( - key=DPCode.CLEAN_AREA, - translation_key="cleaning_area", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CLEAN_TIME, - translation_key="cleaning_time", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_AREA, - translation_key="total_cleaning_area", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_TIME, - translation_key="total_cleaning_time", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_COUNT, - translation_key="total_cleaning_times", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.DUSTER_CLOTH, - translation_key="duster_cloth_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.EDGE_BRUSH, - translation_key="side_brush_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.FILTER_LIFE, - translation_key="filter_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.ROLL_BRUSH, - translation_key="rolling_brush_life", - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_CURRENT, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre - "cl": ( - TuyaSensorEntityDescription( - key=DPCode.TIME_TOTAL, - translation_key="last_operation_duration", - entity_category=EntityCategory.DIAGNOSTIC, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 - "jsq": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_CURRENT, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_F, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.LEVEL_CURRENT, - translation_key="water_level", - entity_category=EntityCategory.DIAGNOSTIC, - ), - ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 - "kj": ( - TuyaSensorEntityDescription( - key=DPCode.FILTER, - translation_key="filter_utilization", - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.PM25, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TVOC, - translation_key="total_volatile_organic_compound", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.ECO2, - translation_key="concentration_carbon_dioxide", - device_class=SensorDeviceClass.CO2, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_TIME, - translation_key="total_operating_time", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_PM, - translation_key="total_absorption_particles", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.AIR_QUALITY, - translation_key="air_quality", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 - "fs": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # eMylo Smart WiFi IR Remote - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_INDOOR, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_INDOOR, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Soil sensor (Plant monitor) - "zwjcy": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), # VESKA-micro inverter "znnbq": ( TuyaSensorEntityDescription( @@ -1314,23 +1300,36 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": BATTERY_SENSORS, + # Soil sensor (Plant monitor) + "zwjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), } # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SENSORS["cz"] = SENSORS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["pc"] = SENSORS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SENSORS["dghsxj"] = SENSORS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 039442dafe5..8003dc2cf21 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -23,6 +23,14 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + entity_category=EntityCategory.CONFIG, + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -44,14 +52,6 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SirenEntityDescription( - key=DPCode.ALARM_SWITCH, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index b786644fd05..9b4cc332d94 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -37,6 +37,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SwitchEntityDescription( + key=DPCode.CONTROL_BACK, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OPPOSITE, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + ), # EasyBaby # Undocumented, might have a wider use "cn": ( @@ -131,6 +145,116 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Power", + icon="mdi:power", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + name="Side A Power", + icon="mdi:alpha-a", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + name="Side B Power", + icon="mdi:alpha-b", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT, + name="Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_1, + name="Side A Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_2, + name="Side B Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="anion", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.HUMIDIFIER, + translation_key="humidification", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OXYGEN, + translation_key="oxygen_bar", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_COOL, + translation_key="natural_wind", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_BEEP, + translation_key="sound", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="switch_3", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="switch_4", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="switch_5", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="switch_6", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_7, + translation_key="switch_7", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_8, + translation_key="switch_8", + ), + ), # Wake Up Light II # Not documented "hxd": ( @@ -163,19 +287,23 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="sleep_aid", ), ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SWITCH_SOUND, + translation_key="voice", + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SLEEP, + translation_key="sleep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.STERILIZATION, + translation_key="sterilization", + entity_category=EntityCategory.CONFIG, ), ), # Switch @@ -408,6 +536,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -429,42 +566,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_3, - translation_key="switch_3", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_4, - translation_key="switch_4", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_5, - translation_key="switch_5", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_6, - translation_key="switch_6", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_7, - translation_key="switch_7", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_8, - translation_key="switch_8", - ), - ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( @@ -552,13 +653,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Hejhome whitelabel Fingerbot - "znjxs": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - ), - ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -606,6 +700,21 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( @@ -636,15 +745,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - device_class=SwitchDeviceClass.OUTLET, - ), - ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( @@ -679,71 +779,11 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + # Hejhome whitelabel Fingerbot + "znjxs": ( SwitchEntityDescription( - key=DPCode.ANION, - translation_key="anion", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.HUMIDIFIER, - translation_key="humidification", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OXYGEN, - translation_key="oxygen_bar", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_COOL, - translation_key="natural_wind", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_BEEP, - translation_key="sound", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.CHILD_LOCK, - translation_key="child_lock", - entity_category=EntityCategory.CONFIG, - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SwitchEntityDescription( - key=DPCode.CONTROL_BACK, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OPPOSITE, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_SOUND, - translation_key="voice", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.SLEEP, - translation_key="sleep", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.STERILIZATION, - translation_key="sterilization", - entity_category=EntityCategory.CONFIG, + key=DPCode.SWITCH, + translation_key="switch", ), ), # Pool HeatPump @@ -753,46 +793,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - name="Power", - icon="mdi:power", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_1, - name="Side A Power", - icon="mdi:alpha-a", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - name="Side B Power", - icon="mdi:alpha-b", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT, - name="Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_1, - name="Side A Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_2, - name="Side B Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - ), } # Socket (duplicate of `pc`) From 39ed877a1784a384a9124969790cdbd2ed44b704 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:43:55 +0200 Subject: [PATCH 0035/1118] Fix unloading update listener in Axis (#148470) --- homeassistant/components/axis/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index e6c6fab47a1..92bd240c736 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -30,7 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.setup() - config_entry.add_update_listener(hub.async_new_address_callback) + config_entry.async_on_unload( + config_entry.add_update_listener(hub.async_new_address_callback) + ) config_entry.async_on_unload(hub.teardown) config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) From e387d4834f7d3141bb7c5ebe24fbc0993c52adb7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:44:21 +0200 Subject: [PATCH 0036/1118] Fix unloading update listener in Unifi (#148471) --- homeassistant/components/unifi/hub/hub.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index f2ed95a0c79..6cf8825a26c 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -91,7 +91,9 @@ class UnifiHub: assert self.config.entry.unique_id is not None self.is_admin = self.api.sites[self.config.entry.unique_id].role == "admin" - self.config.entry.add_update_listener(self.async_config_entry_updated) + self.config.entry.async_on_unload( + self.config.entry.add_update_listener(self.async_config_entry_updated) + ) @property def device_info(self) -> DeviceInfo: From de849b920ae8925e73593b5ef991a01e71e8eb43 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 9 Jul 2025 15:54:49 +0700 Subject: [PATCH 0037/1118] Enable web search for OpenAI reasoning models (#148393) --- .../openai_conversation/config_flow.py | 4 ++-- .../components/openai_conversation/const.py | 13 +++++------ .../openai_conversation/test_config_flow.py | 23 ------------------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 63ebc351ee3..ae1e2f31a85 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -66,7 +66,7 @@ from .const import ( RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, - WEB_SEARCH_MODELS, + UNSUPPORTED_WEB_SEARCH_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -320,7 +320,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if model.startswith(tuple(WEB_SEARCH_MODELS)): + if not model.startswith(tuple(UNSUPPORTED_WEB_SEARCH_MODELS)): step_schema.update( { vol.Optional( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 3f1c0dc7429..6a6a5b2ce6e 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -44,11 +44,10 @@ UNSUPPORTED_MODELS: list[str] = [ "gpt-4o-mini-realtime-preview-2024-12-17", ] -WEB_SEARCH_MODELS: list[str] = [ - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-search-preview", - "gpt-4o-mini", - "gpt-4o-mini-search-preview", +UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ + "gpt-3.5", + "gpt-4-turbo", + "gpt-4.1-nano", + "o1", + "o3-mini", ] diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index b77542fbab3..e845828570c 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -286,29 +286,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "", }, ), - ( # options with no model-specific settings - {}, - ( - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - }, - { - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), ( # options for reasoning models {}, ( From 434ac421d1535277a4cedde6e8f2b27568772d25 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 9 Jul 2025 12:04:00 +0200 Subject: [PATCH 0038/1118] Tiny tweaks to task form (#148475) --- .github/ISSUE_TEMPLATE/task.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml index b5d2b1deb06..5c286613068 100644 --- a/.github/ISSUE_TEMPLATE/task.yml +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -21,7 +21,7 @@ body: - type: textarea id: description attributes: - label: Task description + label: Description description: | Provide a clear and detailed description of the task that needs to be accomplished. @@ -43,9 +43,11 @@ body: Include links to related issues, research, prototypes, roadmap opportunities etc. placeholder: | - - Roadmap opportunity: [links] + - Roadmap opportunity: [link] + - Epic: [link] - Feature request: [link] - Technical design documents: [link] - Prototype/mockup: [link] + - Dependencies: [links] validations: required: false From 659504c91fa2757c1935e165237b0b2bc4ead83b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Jul 2025 12:24:44 +0200 Subject: [PATCH 0039/1118] Fix friendly name of `increased_non_neutral_output` in `zha` (#148468) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 87c3903b342..23d17ea128f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1219,7 +1219,7 @@ "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { - "name": "Non neutral output" + "name": "Increased non-neutral output" }, "leading_or_trailing_edge": { "name": "Dimming mode" From 828037de1f6153842457ffce604c8165d9db5a95 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Wed, 9 Jul 2025 11:25:56 +0100 Subject: [PATCH 0040/1118] Set quality scale on Mealie to silver (#148467) --- homeassistant/components/mealie/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index d90e979582e..0aa9aa86847 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", + "quality_scale": "silver", "requirements": ["aiomealie==0.9.6"] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 46751bda4f8..6d4e536744f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1667,7 +1667,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "matter", "maxcube", "mazda", - "mealie", "meater", "medcom_ble", "media_extractor", From b97b04661eaae1e944a02e2d25c15c4d072a2791 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:29:56 +0200 Subject: [PATCH 0041/1118] Improve logging in bootstrap (#148469) --- homeassistant/bootstrap.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 397f765174d..493b9b1eab6 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -332,6 +332,9 @@ async def async_setup_hass( if not is_virtual_env(): await async_mount_local_lib_path(runtime_config.config_dir) + if hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") + basic_setup_success = ( await async_from_config_dict(config_dict, hass) is not None ) @@ -384,8 +387,6 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) - elif hass.config.safe_mode: - _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) @@ -870,9 +871,9 @@ async def _async_set_up_integrations( domains = set(integrations) & all_domains _LOGGER.info( - "Domains to be set up: %s | %s", - domains, - all_domains - domains, + "Domains to be set up: %s\nDependencies: %s", + domains or "{}", + (all_domains - domains) or "{}", ) async_set_domains_to_be_loaded(hass, all_domains) @@ -913,12 +914,13 @@ async def _async_set_up_integrations( stage_all_domains = stage_domains | stage_dep_domains _LOGGER.info( - "Setting up stage %s: %s | %s\nDependencies: %s | %s", + "Setting up stage %s: %s; already set up: %s\n" + "Dependencies: %s; already set up: %s", name, stage_domains, - stage_domains_unfiltered - stage_domains, - stage_dep_domains, - stage_dep_domains_unfiltered - stage_dep_domains, + (stage_domains_unfiltered - stage_domains) or "{}", + stage_dep_domains or "{}", + (stage_dep_domains_unfiltered - stage_dep_domains) or "{}", ) if timeout is None: From 98604f09fc50cdc139299049245fc803c4258a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 9 Jul 2025 11:30:43 +0100 Subject: [PATCH 0042/1118] Bump hass-nabucasa from 0.105.0 to 0.106.0 (#148473) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0d44d57ac5e..7c64100873c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.105.0"], + "requirements": ["hass-nabucasa==0.106.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d985fae6c5..b73a458b7ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.1 diff --git a/pyproject.toml b/pyproject.toml index 25f4d6d4a1a..3841d234ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.105.0", + "hass-nabucasa==0.106.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index d6912b8898b..c246af65758 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6e949c7a7f4..9f012d492a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 820f681a841..9c02ef478f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 71df8ffe6ed3afc7c0cf02c735e4c2e836651017 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:37:45 +0200 Subject: [PATCH 0043/1118] Bump uiprotect to version 7.14.2 (#148453) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 47e2a01e798..8243a55d779 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 9f012d492a8..d57393004b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2994,7 +2994,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c02ef478f8..a375ebee7f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From ef2e699d2c2a467d9d9283acf589f27f2f4ae941 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:05:53 +0200 Subject: [PATCH 0044/1118] Add tuya snapshot tests for curtain switch (#148465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Franck Nijhof Co-authored-by: Abílio Costa --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/clkg_curtain_switch.json | 95 +++++++++++++++++++ .../components/tuya/snapshots/test_cover.ambr | 52 ++++++++++ .../components/tuya/snapshots/test_light.ambr | 58 +++++++++++ tests/components/tuya/test_cover.py | 57 +++++++++++ tests/components/tuya/test_light.py | 57 +++++++++++ 6 files changed, 324 insertions(+) create mode 100644 tests/components/tuya/fixtures/clkg_curtain_switch.json create mode 100644 tests/components/tuya/snapshots/test_cover.ambr create mode 100644 tests/components/tuya/snapshots/test_light.ambr create mode 100644 tests/components/tuya/test_cover.py create mode 100644 tests/components/tuya/test_light.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5f9c8ef86c6..cc14003bcf5 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,6 +13,11 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "clkg_curtain_switch": [ + # https://github.com/home-assistant/core/issues/136055 + Platform.COVER, + Platform.LIGHT, + ], "cs_arete_two_12l_dehumidifier_air_purifier": [ Platform.FAN, Platform.HUMIDIFIER, diff --git a/tests/components/tuya/fixtures/clkg_curtain_switch.json b/tests/components/tuya/fixtures/clkg_curtain_switch.json new file mode 100644 index 00000000000..28e3248f8b5 --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_curtain_switch.json @@ -0,0 +1,95 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1729466466688hgsTp2", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf1fa053e0ba4e002c6we8", + "name": "Tapparelle studio", + "category": "clkg", + "product_id": "nhyj64w2", + "product_name": "Curtain switch", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-01-13T23:37:14+00:00", + "create_time": "2025-01-13T23:37:14+00:00", + "update_time": "2025-01-13T23:37:14+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "cur_calibration": "end", + "switch_backlight": true, + "control_back_mode": "forward" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr new file mode 100644 index 00000000000..843ee2db6b0 --- /dev/null +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-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.tapparelle_studio_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8control', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'Tapparelle studio Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.tapparelle_studio_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr new file mode 100644 index 00000000000..b9395b3d682 --- /dev/null +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-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': , + 'entity_id': 'light.tapparelle_studio_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8switch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tapparelle studio Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tapparelle_studio_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py new file mode 100644 index 00000000000..6f94896c8c7 --- /dev/null +++ b/tests/components/tuya/test_cover.py @@ -0,0 +1,57 @@ +"""Test Tuya cover platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.COVER in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.COVER not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py new file mode 100644 index 00000000000..cb7639fb662 --- /dev/null +++ b/tests/components/tuya/test_light.py @@ -0,0 +1,57 @@ +"""Test Tuya light platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From b08391903176b94dbd45f43fecb3b3ad3a888b4a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 9 Jul 2025 13:53:15 +0200 Subject: [PATCH 0045/1118] Revert "Deprecate hddtemp" (#148482) --- homeassistant/components/hddtemp/__init__.py | 2 -- homeassistant/components/hddtemp/sensor.py | 20 +------------------ tests/components/hddtemp/test_sensor.py | 21 ++------------------ 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 66a819f1e8d..121238df9fe 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1,3 +1 @@ """The hddtemp component.""" - -DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 192ddffd330..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,14 +22,11 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN - _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -59,21 +56,6 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" - create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - breaks_in_ha_version="2025.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_system_packages_yaml_integration", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "hddtemp", - }, - ) - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 62882c7df8b..56ad9fdcb0e 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,15 +1,12 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from homeassistant.components.hddtemp import DOMAIN -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -195,17 +192,3 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -@patch.dict("sys.modules", gsp=Mock()) -async def test_repair_issue_is_created( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue is created.""" - assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) - await hass.async_block_till_done() - assert ( - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - ) in issue_registry.issues From fe0ce9bc6d2ed50878115542b5a1c23f7333b98f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:44:18 +0200 Subject: [PATCH 0046/1118] Use real product_id in tuya fixture (#148415) --- tests/components/tuya/fixtures/kj_bladeless_tower_fan.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json index 8cbe875718e..909022793ba 100644 --- a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json +++ b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json @@ -7,7 +7,7 @@ "id": "CENSORED", "name": "Bree", "category": "kj", - "product_id": "CENSORED", + "product_id": "yrzylxax1qspdgpp", "product_name": "40\" Bladeless Tower Fan", "online": true, "sub": false, From f6e2b962fdd83f5dd5d29aef03bd824132b1b8a8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:30:17 +0200 Subject: [PATCH 0047/1118] Use SnapshotAssertion in lifx diagnostics tests (#148491) --- .../lifx/snapshots/test_diagnostics.ambr | 292 ++++++++++++++++++ tests/components/lifx/test_diagnostics.py | 271 ++-------------- 2 files changed, 314 insertions(+), 249 deletions(-) create mode 100644 tests/components/lifx/snapshots/test_diagnostics.ambr diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..82499c3632e --- /dev/null +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -0,0 +1,292 @@ +# serializer version: 1 +# name: test_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 1, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_clean_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': True, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hev': dict({ + 'hev_config': dict({ + 'duration': 7200, + 'indication': False, + }), + 'hev_cycle': dict({ + 'duration': 7200, + 'last_power': False, + 'remaining': 30, + }), + 'last_result': 0, + }), + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 90, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_infrared_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': True, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'infrared': dict({ + 'brightness': 65535, + }), + 'kelvin': 4, + 'power': 0, + 'product_id': 29, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_legacy_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 31, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': True, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_ext_mz_firmware': 1532997580, + 'min_ext_mz_firmware_components': list([ + 2, + 77, + ]), + 'min_kelvin': 1500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 38, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 22e335612f8..5883ac046e7 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -1,5 +1,7 @@ """Test LIFX diagnostics.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components import lifx from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -25,7 +27,9 @@ from tests.typing import ClientSessionGenerator async def test_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -45,36 +49,13 @@ async def test_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 1, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_clean_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -94,41 +75,13 @@ async def test_clean_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": True, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hev": { - "hev_config": {"duration": 7200, "indication": False}, - "hev_cycle": {"duration": 7200, "last_power": False, "remaining": 30}, - "last_result": 0, - }, - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 90, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_infrared_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -148,37 +101,13 @@ async def test_infrared_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": True, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "infrared": {"brightness": 65535}, - "kelvin": 4, - "power": 0, - "product_id": 29, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_legacy_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -225,89 +154,13 @@ async def test_legacy_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 31, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -355,84 +208,4 @@ async def test_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": True, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_ext_mz_firmware": 1532997580, - "min_ext_mz_firmware_components": [2, 77], - "min_kelvin": 1500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 38, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot From e1cdc1af1cff2b09f9b226afa538ce79a6686eac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:47:48 +0200 Subject: [PATCH 0048/1118] Add diagnostics tests to tuya (#148489) --- tests/components/tuya/conftest.py | 35 +++- .../tuya/snapshots/test_diagnostics.ambr | 183 ++++++++++++++++++ tests/components/tuya/test_diagnostics.py | 67 +++++++ 3 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tuya/test_diagnostics.py diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 7884597576d..9aa8e8ea147 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -6,7 +6,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerApi, CustomerDevice, DeviceFunction, DeviceStatusRange from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya.const import ( @@ -19,6 +19,7 @@ from homeassistant.components.tuya.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -116,6 +117,12 @@ def mock_manager() -> ManagerCompat: manager = MagicMock(spec=ManagerCompat) manager.device_map = {} manager.mq = MagicMock() + manager.mq.client = MagicMock() + manager.mq.client.is_connected = MagicMock(return_value=True) + manager.customer_api = MagicMock(spec=CustomerApi) + # Meaningless URL / UUIDs + manager.customer_api.endpoint = "https://apigw.tuyaeu.com" + manager.terminal_id = "7cd96aff-6ec8-4006-b093-3dbff7947591" return manager @@ -142,12 +149,34 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev device.product_id = details["product_id"] device.product_name = details["product_name"] device.online = details["online"] + device.sub = details.get("sub") + device.time_zone = details.get("time_zone") + device.active_time = details.get("active_time") + if device.active_time: + device.active_time = int(dt_util.as_timestamp(device.active_time)) + device.create_time = details.get("create_time") + if device.create_time: + device.create_time = int(dt_util.as_timestamp(device.create_time)) + device.update_time = details.get("update_time") + if device.update_time: + device.update_time = int(dt_util.as_timestamp(device.update_time)) + device.support_local = details.get("support_local") + device.mqtt_connected = details.get("mqtt_connected") + device.function = { - key: MagicMock(type=value["type"], values=json_dumps(value["value"])) + key: DeviceFunction( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) for key, value in details["function"].items() } device.status_range = { - key: MagicMock(type=value["type"], values=json_dumps(value["value"])) + key: DeviceStatusRange( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) for key, value in details["status_range"].items() } device.status = details["status"] diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5fc3796d109 --- /dev/null +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -0,0 +1,183 @@ +# serializer version: 1 +# name: test_device_diagnostics[rqbj_gas_sensor] + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'ebb9d0eb5014f98cfboxbz', + 'mqtt_connected': True, + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }) +# --- +# name: test_entry_diagnostics[rqbj_gas_sensor] + dict({ + 'devices': list([ + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'ebb9d0eb5014f98cfboxbz', + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }), + ]), + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'mqtt_connected': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + }) +# --- diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py new file mode 100644 index 00000000000..2009f117efb --- /dev/null +++ b/tests/components/tuya/test_diagnostics.py @@ -0,0 +1,67 @@ +"""Test Tuya diagnostics platform.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import initialize_entry + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +async def test_device_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + device = device_registry.async_get_device(identifiers={(DOMAIN, mock_device.id)}) + assert device, repr(device_registry.devices) + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 59fe6da47ce4d2132ef22d79a20772e495ea8b45 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:59:43 +0200 Subject: [PATCH 0049/1118] Adjust tuya test docstrings (#148493) --- tests/components/tuya/test_binary_sensor.py | 2 +- tests/components/tuya/test_climate.py | 2 +- tests/components/tuya/test_fan.py | 2 +- tests/components/tuya/test_humidifier.py | 2 +- tests/components/tuya/test_light.py | 2 +- tests/components/tuya/test_number.py | 2 +- tests/components/tuya/test_select.py | 2 +- tests/components/tuya/test_sensor.py | 2 +- tests/components/tuya/test_switch.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index ec2120db0b4..c77be47fb2d 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 2ffac1a06d2..a5117983000 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -49,7 +49,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index 736ac0d0691..f6b9a6956bf 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 7b68de17698..f4cd264a03c 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -48,7 +48,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index cb7639fb662..33d0e36715e 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -49,7 +49,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 44ed8eaf9b3..7da514964aa 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index cf6ce169256..c295a07d83f 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index 7f1e71dabc2..d0c6054c135 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -48,7 +48,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 68e8c9e29c4..6164a5c7af8 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( From 511ffdc03c8c3d37a7c7ff74864e8ce0b71a55c5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:20:29 +0200 Subject: [PATCH 0050/1118] Add tuya snapshot tests for kg category (#148492) --- tests/components/tuya/__init__.py | 4 ++ .../tuya/fixtures/kg_smart_valve.json | 56 +++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 49 ++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 tests/components/tuya/fixtures/kg_smart_valve.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index cc14003bcf5..b308df7e2f9 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,10 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "kg_smart_valve": [ + # https://github.com/home-assistant/core/issues/148347 + Platform.SWITCH, + ], "kj_bladeless_tower_fan": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, diff --git a/tests/components/tuya/fixtures/kg_smart_valve.json b/tests/components/tuya/fixtures/kg_smart_valve.json new file mode 100644 index 00000000000..63d9148afbf --- /dev/null +++ b/tests/components/tuya/fixtures/kg_smart_valve.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1750526976566fMhqJs", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "id": "0665305284f3ebe9fdc1", + "name": "QT-Switch", + "category": "kg", + "product_id": "gbm9ata1zrzaez4a", + "product_name": "Smart Valve", + "online": false, + "sub": false, + "time_zone": "-05:00", + "active_time": "2020-01-27T23:37:47+00:00", + "create_time": "2020-01-27T23:37:47+00:00", + "update_time": "2020-01-27T23:37:47+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index c4e813ddfdc..0f042cbce52 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-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.qt_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_1', + 'unique_id': 'tuya.0665305284f3ebe9fdc1switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'QT-Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.qt_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6f31057d308a2de2c1d547e38ee2a0cecf62f1be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 9 Jul 2025 17:01:17 +0200 Subject: [PATCH 0051/1118] Rework Snapcast config flow tests (#148434) --- tests/components/snapcast/conftest.py | 20 +-- tests/components/snapcast/test_config_flow.py | 120 ++++++++++-------- 2 files changed, 76 insertions(+), 64 deletions(-) diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index 9c8a0bc5668..c2c4ffa7997 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -10,7 +10,6 @@ from snapcast.control.server import CONTROL_PORT from snapcast.control.stream import Snapstream from homeassistant.components.snapcast.const import DOMAIN -from homeassistant.components.snapcast.coordinator import Snapserver from homeassistant.const import CONF_HOST, CONF_PORT from tests.common import MockConfigEntry @@ -25,6 +24,16 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_server(mock_create_server: AsyncMock) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.config_flow.snapcast.control.create_server", + return_value=mock_create_server, + ) as mock_server: + yield mock_server + + @pytest.fixture def mock_create_server( mock_group: AsyncMock, @@ -64,15 +73,6 @@ async def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_server_connection() -> Generator[Snapserver]: - """Create a mock server connection.""" - - # Patch the start method of the Snapserver class to avoid network connections - with patch.object(Snapserver, "start", new_callable=AsyncMock) as mock_start: - yield mock_start - - @pytest.fixture def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: """Create a mock Snapgroup.""" diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index 50ab4f0c170..5b7d30211e1 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -1,91 +1,103 @@ """Test the Snapcast module.""" import socket -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries, setup from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") +TEST_CONNECTION = {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test we get the form and handle errors and successful connection.""" - await setup.async_setup_component(hass, "persistent_notification", {}) +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_server: AsyncMock +) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - # test invalid host error - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} - - # test connection error - with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - # test success - with patch("snapcast.control.create_server"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Snapcast" - assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert result["data"] == {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test config flow abort if device is already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONNECTION, - ) - entry.add_to_hass(hass) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_server: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we get the form and handle errors and successful connection.""" + mock_server.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_server.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" From 3045f67ae5f5ba4f6cd8f8e2a7e20154b8c9540e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:49:28 -0400 Subject: [PATCH 0052/1118] Modernize binary sensor template tests (#148367) --- tests/components/template/conftest.py | 82 + .../components/template/test_binary_sensor.py | 1802 ++++++++--------- 2 files changed, 963 insertions(+), 921 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 6d1776f24cd..c57d1dcbfab 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -23,6 +23,88 @@ class ConfigurationStyle(Enum): TRIGGER = "Trigger" +def make_test_trigger(*entities: str) -> dict: + """Make a test state trigger.""" + return { + "trigger": [ + { + "trigger": "state", + "entity_id": list(entities), + }, + {"platform": "event", "event_type": "test_event"}, + ], + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], + } + + +async def async_setup_legacy_platforms( + hass: HomeAssistant, + domain: str, + slug: str, + count: int, + config: ConfigType, +) -> None: + """Do setup of any legacy platform that supports a keyed dictionary of template entities.""" + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + {domain: {"platform": "template", slug: config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_state_format( + hass: HomeAssistant, + domain: str, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via modern format.""" + extra = extra_config or {} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": {domain: config, **extra}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_trigger_format( + hass: HomeAssistant, + domain: str, + trigger: dict, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via trigger format.""" + extra = extra_config or {} + config = {"template": {domain: config, **trigger, **extra}} + + 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 def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index a3b7edea919..75a9e2c9689 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,9 +1,9 @@ """The tests for the Template Binary sensor platform.""" from collections.abc import Generator -from copy import deepcopy from datetime import UTC, datetime, timedelta import logging +from typing import Any from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory @@ -23,16 +23,26 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component + +from .conftest import ( + ConfigurationStyle, + async_get_flow_preview_state, + async_setup_legacy_platforms, + async_setup_modern_state_format, + async_setup_modern_trigger_format, + make_test_trigger, +) from tests.common import ( MockConfigEntry, - assert_setup_component, async_fire_time_changed, + async_mock_restore_state_shutdown_restart, mock_restore_cache, mock_restore_cache_with_extra_data, ) +from tests.typing import WebSocketGenerator _BEER_TRIGGER_VALUE_TEMPLATE = ( "{% if trigger.event.data.beer < 0 %}" @@ -45,94 +55,202 @@ _BEER_TRIGGER_VALUE_TEMPLATE = ( ) -@pytest.mark.parametrize("count", [1]) -@pytest.mark.parametrize( - ("config", "domain", "entity_id", "name", "attributes"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "value_template": "{{ True }}", - } - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - "test", - {"friendly_name": "test"}, - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ True }}", - } - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - "unnamed device", - {}, - ), - ], +TEST_OBJECT_ID = "test_binary_sensor" +TEST_ENTITY_ID = f"binary_sensor.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "binary_sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" +TEST_STATE_TRIGGER = make_test_trigger( + TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID, TEST_ATTRIBUTE_ENTITY_ID ) -@pytest.mark.usefixtures("start_ha") -async def test_setup_minimal( - hass: HomeAssistant, entity_id: str, name: str, attributes: dict[str, str] +UNIQUE_ID_CONFIG = { + "unique_id": "not-so-unique-anymore", +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, config: ConfigType ) -> None: - """Test the setup.""" - state = hass.states.get(entity_id) - assert state is not None - assert state.name == name - assert state.state == STATE_ON - assert state.attributes == attributes + """Do setup of binary sensor integration via legacy format.""" + await async_setup_legacy_platforms( + hass, binary_sensor.DOMAIN, "sensors", count, config + ) + + +async def async_setup_modern_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via modern format.""" + await async_setup_modern_state_format( + hass, binary_sensor.DOMAIN, count, config, extra_config + ) + + +async def async_setup_trigger_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via trigger format.""" + await async_setup_modern_trigger_format( + hass, binary_sensor.DOMAIN, TEST_STATE_TRIGGER, count, config, extra_config + ) + + +@pytest.fixture +async def setup_base_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: ConfigType | list[dict], + extra_template_options: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, config, extra_template_options) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, config, extra_template_options) + + +async def async_setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: {"value_template": state_template, **extra_config}}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict[str, Any], +) -> None: + """Do setup of binary sensor integration.""" + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) + + +@pytest.fixture +async def setup_single_attribute_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_value: str | dict, + state_template: str, + extra_config: dict, +) -> None: + """Do setup of binary sensor integration testing a single attribute.""" + extra = {attribute: attribute_value} if attribute and attribute_value else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config"), [(1, "{{ True }}", {})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup_minimal(hass: HomeAssistant) -> None: + """Test the setup.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.name == TEST_OBJECT_ID + assert state.state == STATE_ON + assert state.attributes == {"friendly_name": TEST_OBJECT_ID} + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), [ ( + 1, + "{{ True }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", - "device_class": "motion", - } - }, - }, + "device_class": "motion", }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "{{ True }}", - "device_class": "motion", - } - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_setup(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup(hass: HomeAssistant) -> None: """Test the setup.""" - state = hass.states.get(entity_id) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert state.name == "virtual thingy" + assert state.name == TEST_OBJECT_ID assert state.state == STATE_ON assert state.attributes["device_class"] == "motion" @@ -298,162 +416,144 @@ async def test_state( assert state.state == expected_result -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "icon_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "icon": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}mdi:check{% endif %}", + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), + (ConfigurationStyle.TRIGGER, "icon", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_icon_template(hass: HomeAssistant, initial_state: str | None) -> None: """Test icon template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("icon") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("binary_sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "entity_picture_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "picture": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}/local/sensor.png{% endif %}", + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template", ""), + (ConfigurationStyle.MODERN, "picture", ""), + (ConfigurationStyle.TRIGGER, "picture", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_entity_picture_template( + hass: HomeAssistant, initial_state: str | None +) -> None: """Test entity_picture template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("entity_picture") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set("binary_sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/sensor.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "attributes": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ True }}", + {"test_attribute": "It {{ states.sensor.test_attribute.state }}."}, + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_attribute_templates(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_value"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates", "It ."), + (ConfigurationStyle.MODERN, "attributes", "It ."), + (ConfigurationStyle.TRIGGER, "attributes", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_attribute_templates( + hass: HomeAssistant, initial_value: str | None +) -> None: """Test attribute_templates template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("test_attribute") == "It ." - hass.states.async_set("sensor.test_state", "Works2") + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("test_attribute") == initial_value + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works2") await hass.async_block_till_done() - hass.states.async_set("sensor.test_state", "Works") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works") await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It Works." +@pytest.mark.parametrize( + ("count", "state_template", "attribute_value", "extra_config"), + [ + ( + 1, + "{{ states.binary_sensor.test_sensor }}", + {"test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}"}, + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates"), + (ConfigurationStyle.MODERN, "attributes"), + (ConfigurationStyle.TRIGGER, "attributes"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_invalid_attribute_template( + hass: HomeAssistant, + style: ConfigurationStyle, + caplog_setup_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that errors are logged if rendering template fails.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + text = ( + "Template variable error: 'None' has no attribute 'attributes' when rendering" + ) + assert text in caplog_setup_text or text in caplog.text + + @pytest.fixture def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" @@ -496,338 +596,261 @@ async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: assert len(setup_mock.mock_calls) == init_calls -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "extra_config"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - }, - }, - }, - }, + ( + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_event(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> None: """Test the event.""" - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == initial_state - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON @pytest.mark.parametrize( - ("config", "count", "domain"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, 1, - binary_sensor.DOMAIN, - ), - ( - { - "template": [ - { - "binary_sensor": { - "name": "test on", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - { - "binary_sensor": { - "name": "test off", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - ] - }, - 2, - template.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_on", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_delay_on_off( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_on( + hass: HomeAssistant, initial_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test binary sensor template delay on.""" # Ensure the initial state is not on - assert hass.states.get("binary_sensor.test_on").state != STATE_ON - assert hass.states.get("binary_sensor.test_off").state != STATE_ON + assert hass.states.get(TEST_ENTITY_ID).state == initial_state - hass.states.async_set("input_number.delay", 5) - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + + assert hass.states.get(TEST_ENTITY_ID).state == initial_state freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_ON - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - # check with time changes - hass.states.async_set("sensor.test_state", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_OFF) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_OFF + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_off", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_available_without_availability_template( - hass: HomeAssistant, entity_id: str -) -> None: +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Test binary sensor template delay off.""" + assert hass.states.get(TEST_ENTITY_ID).state != STATE_ON + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), + [ + ( + 1, + "{{ True }}", + { + "device_class": "motion", + "delay_off": 5, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_available_without_availability_template(hass: HomeAssistant) -> None: """Ensure availability is true without an availability_template.""" - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( + 1, + "{{ True }}", + "{{ is_state('binary_sensor.test_availability','on') }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, + "device_class": "motion", + "delay_off": 5, }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - "availability": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" - hass.states.async_set("sensor.test_state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ True }}", "{{ x - 12 }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "invalid_template": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "attribute_templates": { - "test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}" - }, - } - }, - }, - }, + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text: str -) -> None: - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("binary_sensor.test_sensor", STATE_ON) - assert len(hass.states.async_all()) == 2 - assert ("test_attribute") in caplog_setup_text - assert ("TemplateError") in caplog_setup_text - - -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "my_sensor": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "availability_template": "{{ x - 12 }}", - }, - }, - }, - }, - ], -) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text: str + hass: HomeAssistant, caplog_setup_text: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - assert "UndefinedError: 'x' is undefined" in caplog_setup_text + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + text = "UndefinedError: 'x' is undefined" + assert text in caplog_setup_text or text in caplog.text async def test_no_update_template_match_all(hass: HomeAssistant) -> None: @@ -896,172 +919,145 @@ async def test_no_update_template_match_all(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.all_attribute").state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize(("count", "extra_template_options"), [(1, {})]) @pytest.mark.parametrize( - "config", + ("config", "style"), [ - { - "template": { - "unique_id": "group-id", - "binary_sensor": { - "name": "top-level", - "unique_id": "sensor-id", - "state": STATE_ON, + ( + { + "test_template_01": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_02": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, }, }, - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_cover_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_cover_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, }, - }, - }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id( +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one fan per id.""" + assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("count", "config", "extra_template_options"), + [ + ( + 1, + [ + { + "name": "test_a", + "state": "{{ True }}", + "unique_id": "a", + }, + { + "name": "test_b", + "state": "{{ True }}", + "unique_id": "b", + }, + ], + {"unique_id": "x"}, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_nested_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test unique_id option only creates one binary sensor per id.""" - assert len(hass.states.async_all()) == 2 + """Test a template unique_id propagates to switch unique_ids.""" + assert len(hass.states.async_all("binary_sensor")) == 2 - assert len(entity_registry.entities) == 2 - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "group-id-sensor-id" - ) - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "not-so-unique-anymore" - ) + entry = entity_registry.async_get("binary_sensor.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("binary_sensor.test_b") + assert entry + assert entry.unique_id == "x-b" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ 1 == 1 }}", "{{ states.sensor.test_attribute.state }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "True", - "icon_template": "{{ states.sensor.test_state.state }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - }, - }, + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_validation_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_template_icon_validation_error( + hass: HomeAssistant, initial_state: str, caplog: pytest.LogCaptureFixture ) -> None: """Test binary sensor template delay on.""" caplog.set_level(logging.ERROR) - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("sensor.test_state", "mdi:check") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "mdi:check") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "mdi:check" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" - hass.states.async_set("sensor.test_state", "invalid_icon") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "invalid_icon") await hass.async_block_till_done() + assert len(caplog.records) == 1 assert caplog.records[0].message.startswith( "Error validating template result 'invalid_icon' from template" ) - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") is None -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "availability_template": "{{ is_state('sensor.bla', 'available') }}", - "entity_picture_template": "{{ 'blib' + 'blub' }}", - "icon_template": "mdi:{{ 1+2 }}", - "friendly_name": "{{ 'My custom ' + 'sensor' }}", - "value_template": "{{ true }}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "availability": "{{ is_state('sensor.bla', 'available') }}", - "picture": "{{ 'blib' + 'blub' }}", - "icon": "mdi:{{ 1+2 }}", - "name": "{{ 'My custom ' + 'sensor' }}", - "state": "{{ true }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.my_custom_sensor", - ), - ], + ("count", "state_template"), [(1, "{{ states.binary_sensor.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> None: - """Test name, icon and picture templates are rendered at setup.""" - state = hass.states.get(entity_id) - assert state.state == "unavailable" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - hass.states.async_set("sensor.bla", "available") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == "on" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - "config", - [ - { - "template": { - "binary_sensor": { - "name": "test", - "state": "{{ states.sensor.test_state.state }}", - }, - }, - }, - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.parametrize( ("extra_config", "source_state", "restored_state", "initial_state"), @@ -1107,8 +1103,8 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> async def test_restore_state( hass: HomeAssistant, count: int, - domain: str, - config: ConfigType, + style: ConfigurationStyle, + state_template: str, extra_config: ConfigType, source_state: str | None, restored_state: str, @@ -1116,199 +1112,33 @@ async def test_restore_state( ) -> None: """Test restoring template binary sensor.""" - hass.states.async_set("sensor.test_state", source_state) - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + hass.states.async_set(TEST_STATE_ENTITY_ID, source_state) + await hass.async_block_till_done() + + fake_state = State(TEST_ENTITY_ID, restored_state, {}) mock_restore_cache(hass, (fake_state,)) - config = deepcopy(config) - config["template"]["binary_sensor"].update(**extra_config) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) - await hass.async_block_till_done() + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) - context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state -@pytest.mark.parametrize(("count", "domain"), [(2, "template")]) @pytest.mark.parametrize( - "config", + ("count", "style", "state_template", "extra_config"), [ - { - "template": [ - {"invalid": "config"}, - # Config after invalid should still be set up - { - "unique_id": "listening-test-event", - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensors": { - "hello": { - "friendly_name": "Hello Name", - "unique_id": "hello_name-id", - "device_class": "battery", - "value_template": _BEER_TRIGGER_VALUE_TEMPLATE, - "entity_picture_template": "{{ '/local/dogs.png' }}", - "icon_template": "{{ 'mdi:pirate' }}", - "attribute_templates": { - "plus_one": "{{ trigger.event.data.beer + 1 }}" - }, - }, - }, - "binary_sensor": [ - { - "name": "via list", - "unique_id": "via_list-id", - "device_class": "battery", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - } - ], - }, - { - "trigger": [], - "binary_sensors": { - "bare_minimum": { - "value_template": "{{ trigger.event.data.beer == 1 }}" - }, - }, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ( - "beer_count", - "final_state", - "icon_attr", - "entity_picture_attr", - "plus_one_attr", - "another_attr", - "another_attr_update", - ), - [ - (2, STATE_ON, "mdi:pirate", "/local/dogs.png", 3, 1, "si"), - (1, STATE_OFF, "mdi:pirate", "/local/dogs.png", 2, 1, "si"), - (0, STATE_UNKNOWN, "mdi:pirate", "/local/dogs.png", 1, 1, "si"), - (-1, STATE_UNAVAILABLE, None, None, None, None, None), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_trigger_entity( - hass: HomeAssistant, - beer_count: int, - final_state: str, - icon_attr: str | None, - entity_picture_attr: str | None, - plus_one_attr: int | None, - another_attr: int | None, - another_attr_update: str | None, - entity_registry: er.EntityRegistry, -) -> None: - """Test trigger entity works.""" - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state is not None - assert state.state == STATE_UNKNOWN - - state = hass.states.get("binary_sensor.bare_minimum") - assert state is not None - assert state.state == STATE_UNKNOWN - - context = Context() - hass.bus.async_fire("test_event", {"beer": beer_count}, context=context) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.hello_name") - assert state.state == final_state - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == icon_attr - assert state.attributes.get("entity_picture") == entity_picture_attr - assert state.attributes.get("plus_one") == plus_one_attr - assert state.context is context - - assert len(entity_registry.entities) == 2 - assert ( - entity_registry.entities["binary_sensor.hello_name"].unique_id - == "listening-test-event-hello_name-id" - ) - assert ( - entity_registry.entities["binary_sensor.via_list"].unique_id - == "listening-test-event-via_list-id" - ) - - state = hass.states.get("binary_sensor.via_list") - assert state.state == final_state - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == icon_attr - assert state.attributes.get("entity_picture") == entity_picture_attr - assert state.attributes.get("plus_one") == plus_one_attr - assert state.attributes.get("another") == another_attr - assert state.context is context - - # Even if state itself didn't change, attributes might have changed - hass.bus.async_fire("test_event", {"beer": beer_count, "uno_mas": "si"}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.via_list") - assert state.state == final_state - assert state.attributes.get("another") == another_attr_update - - # Check None values - hass.bus.async_fire("test_event", {"beer": 0}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_UNKNOWN - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_UNKNOWN - - # Check impossible values - hass.bus.async_fire("test_event", {"beer": -1}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_UNAVAILABLE - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, + ( + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', }, - }, + ) ], ) -@pytest.mark.usefixtures("start_ha") @pytest.mark.parametrize( ("beer_count", "first_state", "second_state", "final_state"), [ @@ -1318,7 +1148,8 @@ async def test_trigger_entity( (-1, STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE), ], ) -async def test_template_with_trigger_templated_delay_on( +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_templated_auto_off( hass: HomeAssistant, beer_count: int, first_state: str, @@ -1326,8 +1157,8 @@ async def test_template_with_trigger_templated_delay_on( final_state: str, freezer: FrozenDateTimeFactory, ) -> None: - """Test binary sensor template with template delay on.""" - state = hass.states.get("binary_sensor.test") + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN context = Context() @@ -1335,7 +1166,7 @@ async def test_template_with_trigger_templated_delay_on( await hass.async_block_till_done() # State should still be unknown - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == first_state # Now wait for the on delay @@ -1343,7 +1174,7 @@ async def test_template_with_trigger_templated_delay_on( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == second_state # Now wait for the auto-off @@ -1351,52 +1182,128 @@ async def test_template_with_trigger_templated_delay_on( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == final_state -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - ("config", "delay_state"), + ("count", "style", "state_template", "extra_config"), [ ( + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 }) }}', - }, - }, + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 5 }) }}', }, - STATE_ON, - ), - ( - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer != 2 }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 }) }}', - }, - }, - }, - STATE_OFF, - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_auto_off_cancel( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # State should still be unknown + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the auto-off + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "style", "extra_config", "attribute_value"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ states('sensor.test_attribute') }}", + ) + ], +) +@pytest.mark.parametrize( + ("state_template", "attribute"), + [ + ("{{ True }}", "delay_on"), + ("{{ False }}", "delay_off"), + ("{{ True }}", "auto_off"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_trigger_with_negative_time_periods( + hass: HomeAssistant, attribute: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary sensor template with template negative time periods.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "-5") + await hass.async_block_till_done() + + assert f"Error rendering {attribute} template: " in caplog.text + + +@pytest.mark.parametrize( + ("count", "style", "extra_config", "attribute_value"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ ({ 'seconds': 10 }) }}", + ) + ], +) +@pytest.mark.parametrize( + ("state_template", "attribute", "delay_state"), + [ + ("{{ trigger.event.data.beer == 2 }}", "delay_on", STATE_ON), + ("{{ trigger.event.data.beer != 2 }}", "delay_off", STATE_OFF), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_trigger_template_delay_with_multiple_triggers( hass: HomeAssistant, delay_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test trigger based binary sensor with multiple triggers occurring during the delay.""" for _ in range(10): # State should still be unknown - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) @@ -1406,32 +1313,10 @@ async def test_trigger_template_delay_with_multiple_triggers( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == delay_state -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - }, - }, - }, - ], -) @pytest.mark.parametrize( ("restored_state", "initial_state", "initial_attributes"), [ @@ -1443,9 +1328,6 @@ async def test_trigger_template_delay_with_multiple_triggers( ) async def test_trigger_entity_restore_state( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, restored_state: str, initial_state: str, initial_attributes: list[str], @@ -1459,7 +1341,7 @@ async def test_trigger_entity_restore_state( } fake_state = State( - "binary_sensor.test", + TEST_ENTITY_ID, restored_state, restored_attributes, ) @@ -1467,18 +1349,23 @@ async def test_trigger_entity_restore_state( "auto_off_time": None, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + "attributes": { + "plus_one": "{{ trigger.event.data.beer + 1 }}", + "another": "{{ trigger.event.data.uno_mas or 1 }}", + }, + }, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state for attr, value in restored_attributes.items(): if attr in initial_attributes: @@ -1490,7 +1377,7 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"beer": 2}) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" @@ -1498,40 +1385,16 @@ async def test_trigger_entity_restore_state( assert state.attributes["another"] == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) @pytest.mark.parametrize("restored_state", [STATE_ON, STATE_OFF]) async def test_trigger_entity_restore_state_auto_off( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, restored_state: str, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + fake_state = State(TEST_ENTITY_ID, restored_state, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1539,18 +1402,15 @@ async def test_trigger_entity_restore_state_auto_off( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == restored_state # Now wait for the auto-off @@ -1558,42 +1418,18 @@ async def test_trigger_entity_restore_state_auto_off( await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) async def test_trigger_entity_restore_state_auto_off_expired( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - STATE_ON, - {}, - ) + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1601,21 +1437,132 @@ async def test_trigger_entity_restore_state_auto_off_expired( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF +async def test_saving_auto_off( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test we restore state integration.""" + restored_attributes = { + "entity_picture": "/local/cats.png", + "icon": "mdi:ship", + "plus_one": 55, + } + + freezer.move_to("2022-02-02 02:02:00+00:00") + fake_extra_data = { + "auto_off_time": { + "__type": "", + "isoformat": "2022-02-02T02:02:02+00:00", + }, + } + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", + { + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', + "attributes": restored_attributes, + }, + ) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_ENTITY_ID + + for attr, value in restored_attributes.items(): + assert state["attributes"][attr] == value + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + +async def test_trigger_entity_restore_invalid_auto_off_time_data( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_time": { + "_type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +async def test_trigger_entity_restore_invalid_auto_off_time_key( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_timex": { + "__type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert "auto_off_timex" in extra_data + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1653,3 +1600,16 @@ async def test_device_id( template_entity = entity_registry.async_get("binary_sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + binary_sensor.DOMAIN, + {"name": "My template", "state": "{{ 'on' }}"}, + ) + assert state["state"] == "on" From 57083d877e0a8b0a53a89c8b58400948e7acceff Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 9 Jul 2025 19:52:16 +0200 Subject: [PATCH 0053/1118] Add repairs from issue registry to integration diagnostics (#148498) --- .../components/diagnostics/__init__.py | 16 ++++- tests/components/diagnostics/test_init.py | 60 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 7bc43f2c3f5..715285d184e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, integration_platform, + issue_registry as ir, ) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( @@ -187,6 +188,7 @@ def async_format_manifest(manifest: Manifest) -> Manifest: async def _async_get_json_file_response( hass: HomeAssistant, data: Mapping[str, Any], + data_issues: list[dict[str, Any]] | None, filename: str, domain: str, d_id: str, @@ -213,6 +215,8 @@ async def _async_get_json_file_response( "setup_times": async_get_domain_setup_times(hass, domain), "data": data, } + if data_issues is not None: + payload["issues"] = data_issues try: json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder) except TypeError: @@ -275,6 +279,14 @@ class DownloadDiagnosticsView(http.HomeAssistantView): filename = f"{config_entry.domain}-{config_entry.entry_id}" + issue_registry = ir.async_get(hass) + issues = issue_registry.issues + data_issues = [ + issue_reg.to_json() + for issue_id, issue_reg in issues.items() + if issue_id[0] == config_entry.domain + ] + if not device_diagnostics: # Config entry diagnostics if info.config_entry_diagnostics is None: @@ -282,7 +294,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.config_entry_diagnostics(hass, config_entry) filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}" return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id + hass, data, data_issues, filename, config_entry.domain, d_id ) # Device diagnostics @@ -300,5 +312,5 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.device_diagnostics(hass, config_entry, device) return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id, sub_id + hass, data, data_issues, filename, config_entry.domain, d_id, sub_id ) diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index ffed7e21f60..fe62efeebac 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -1,13 +1,15 @@ """Test the Diagnostics integration.""" +from datetime import datetime from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time import pytest from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.system_info import async_get_system_info from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -81,10 +83,20 @@ async def test_websocket( @pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.parametrize( + "ignore_missing_translations", + [ + [ + "component.fake_integration.issues.test_issue.title", + "component.fake_integration.issues.test_issue.description", + ] + ], +) async def test_download_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test download diagnostics.""" config_entry = MockConfigEntry(domain="fake_integration") @@ -95,6 +107,18 @@ async def test_download_diagnostics( integration = await async_get_integration(hass, "fake_integration") original_manifest = integration.manifest.copy() original_manifest["codeowners"] = ["@test"] + + with freeze_time(datetime(2025, 7, 9, 14, 00, 00)): + issue_registry.async_get_or_create( + domain="fake_integration", + issue_id="test_issue", + breaks_in_ha_version="2023.10.0", + severity=ir.IssueSeverity.WARNING, + is_fixable=False, + is_persistent=True, + translation_key="test_issue", + ) + with patch.object(integration, "manifest", original_manifest): response = await _get_diagnostics_for_config_entry( hass, hass_client, config_entry @@ -179,6 +203,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"config_entry": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], } device = device_registry.async_get_or_create( @@ -266,6 +307,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"device": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], "setup_times": {}, } From 1b5bbda6b011f1591f486f2b57960136208ef904 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 9 Jul 2025 20:37:00 +0200 Subject: [PATCH 0054/1118] Add response headers to action response of rest command (#148480) --- homeassistant/components/rest_command/__init__.py | 6 +++++- tests/components/rest_command/test_init.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c6a4206de4a..0a9632b864d 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -205,7 +205,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "decoding_type": "text", }, ) from err - return {"content": _content, "status": response.status} + return { + "content": _content, + "status": response.status, + "headers": dict(response.headers), + } except TimeoutError as err: raise HomeAssistantError( diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 97ef29dfaca..5549aa67815 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -290,6 +290,7 @@ async def test_rest_command_get_response_plaintext( assert len(aioclient_mock.mock_calls) == 1 assert response["content"] == "success" assert response["status"] == 200 + assert response["headers"] == {"content-type": "text/plain"} async def test_rest_command_get_response_json( @@ -314,6 +315,7 @@ async def test_rest_command_get_response_json( assert response["content"]["status"] == "success" assert response["content"]["number"] == 42 assert response["status"] == 200 + assert response["headers"] == {"content-type": "application/json"} async def test_rest_command_get_response_malformed_json( From cbdc8e38004b55e8dee0d99020ead7aa3f34634d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 9 Jul 2025 12:45:45 -0600 Subject: [PATCH 0055/1118] Bump pylitterbot to 2024.2.2 (#148505) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index a8945e482bf..33addd85ba2 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.2.1"] + "requirements": ["pylitterbot==2024.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d57393004b2..0b67ad8e1df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2118,7 +2118,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.1 +pylitterbot==2024.2.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a375ebee7f3..c377220d3c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1763,7 +1763,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.1 +pylitterbot==2024.2.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 From 5d43938f0d1a6be4e7c7a72fbc48fc278b934d06 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:20:38 +0200 Subject: [PATCH 0056/1118] Bump `imgw_pib` to version 1.2.0 (#148511) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 2 ++ tests/components/imgw_pib/snapshots/test_diagnostics.ambr | 8 ++++++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 42d536da8f5..631bce3fbc9 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.1.0"] + "requirements": ["imgw_pib==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b67ad8e1df..00636689c5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.2.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c377220d3c2..9c2b6a37d29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.2.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index a10b9b54532..e0b091e5ff3 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -23,6 +23,8 @@ HYDROLOGICAL_DATA = HydrologicalData( flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), + water_flow=SensorData(name="Water Flow", value=123.45), + water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 97453930c1e..08f3690136e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -34,9 +34,17 @@ 'unit': None, 'value': None, }), + 'latitude': None, + 'longitude': None, 'river': 'River Name', 'station': 'Station Name', 'station_id': '123', + 'water_flow': dict({ + 'name': 'Water Flow', + 'unit': None, + 'value': 123.45, + }), + 'water_flow_measurement_date': '2024-04-27T10:05:00+00:00', 'water_level': dict({ 'name': 'Water Level', 'unit': None, From e012196af8c4ac7f1308e8c911c3706124d6c49b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:22:31 +0200 Subject: [PATCH 0057/1118] Bump aioimmich to 0.10.2 (#148503) --- homeassistant/components/immich/manifest.json | 2 +- homeassistant/components/immich/update.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 80dcd87cd88..906356a4bc9 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.10.1"] + "requirements": ["aioimmich==0.10.2"] } diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py index 9955e355c96..e0af5c1c67f 100644 --- a/homeassistant/components/immich/update.py +++ b/homeassistant/components/immich/update.py @@ -44,7 +44,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity): return self.coordinator.data.server_about.version @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Available new immich server version.""" assert self.coordinator.data.server_version_check return self.coordinator.data.server_version_check.release_version diff --git a/requirements_all.txt b/requirements_all.txt index 00636689c5e..bfff2521a61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c2b6a37d29..862043fbfdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 84959a007737dc4f5878cccb3dc02fc6ea6cc614 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:33:07 +0200 Subject: [PATCH 0058/1118] Add platinum quality scale to Pegel Online (#131382) --- .../components/pegel_online/__init__.py | 8 +- .../components/pegel_online/manifest.json | 1 + .../pegel_online/quality_scale.yaml | 87 +++++++++++++++++++ .../components/pegel_online/sensor.py | 3 + script/hassfest/quality_scale.py | 2 - 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/pegel_online/quality_scale.yaml diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 1c71603e41e..c8388f40704 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION +from .const import CONF_STATION, DOMAIN from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) try: station = await api.async_get_station_details(station_uuid) except CONNECT_ERRORS as err: - raise ConfigEntryNotReady("Failed to connect") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 0a0f31532b1..c488eca34af 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], + "quality_scale": "platinum", "requirements": ["aiopegelonline==0.1.1"] } diff --git a/homeassistant/components/pegel_online/quality_scale.yaml b/homeassistant/components/pegel_online/quality_scale.yaml new file mode 100644 index 00000000000..aa0a153ee9c --- /dev/null +++ b/homeassistant/components/pegel_online/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions/services are implemented + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions/services are implemented + 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: no actions/services are implemented + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication necessary + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: pure webservice, no discovery + discovery: + status: exempt + comment: pure webservice, no discovery + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: not applicable - see stale-devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + each config entry represents only one named measurement station, + so when the user wants to add another one, they can just add another config entry + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + does not apply, since only one measurement station per config-entry + if a measurement station is removed from the data provider, + the user can just remove the related config entry + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index ee2e6750911..30d4edfb041 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PegelOnlineSensorEntityDescription(SensorEntityDescription): diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 6d4e536744f..b5fd8c3ad7a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -763,7 +763,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pandora", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", @@ -1818,7 +1817,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "palazzetti", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", From 283d0d16c05634f8a1b10c06d3e4d739cb65acd9 Mon Sep 17 00:00:00 2001 From: Mickael Goubin Date: Wed, 9 Jul 2025 21:33:15 +0200 Subject: [PATCH 0059/1118] Linkplay - when grouped, the first media player returned is the coordinator (#146295) Co-authored-by: Joost Lekkerkerker --- .../components/linkplay/media_player.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 89cc498ed01..ee1cdfe67e8 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus @@ -315,14 +315,19 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): return [] shared_data = self.hass.data[DOMAIN][SHARED_DATA] + leader_id: str | None = None + followers = [] - return [ - entity_id - for entity_id, bridge in shared_data.entity_to_bridge.items() - if bridge - in [multiroom.leader.device.uuid] - + [follower.device.uuid for follower in multiroom.followers] - ] + # find leader and followers + for ent_id, uuid in shared_data.entity_to_bridge.items(): + if uuid == multiroom.leader.device.uuid: + leader_id = ent_id + elif uuid in {f.device.uuid for f in multiroom.followers}: + followers.append(ent_id) + + if TYPE_CHECKING: + assert leader_id is not None + return [leader_id, *followers] @property def media_image_url(self) -> str | None: From 2807f057dec574bff2bb779043f329171810c5ab Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:34:37 +0200 Subject: [PATCH 0060/1118] Fix flaky test in Husqvarna Automower (#148515) --- tests/components/husqvarna_automower/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index f2b468c4faf..9a45b2ad42d 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -262,7 +262,7 @@ async def test_constant_polling( test_values[TEST_MOWER_ID].battery.battery_percent = 77 - freezer.tick(SCAN_INTERVAL - timedelta(seconds=1)) + freezer.tick(SCAN_INTERVAL - timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -278,7 +278,7 @@ async def test_constant_polling( test_values[TEST_MOWER_ID].work_areas[123456].progress = 50 mock_automower_client.get_status.return_value = test_values - freezer.tick(timedelta(seconds=4)) + freezer.tick(timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() mock_automower_client.get_status.assert_awaited() From e42ca06173e4d796b9913e471654a5ff7a47b88a Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 10 Jul 2025 02:41:50 +0700 Subject: [PATCH 0061/1118] Bump openai to 1.93.3 (#148501) --- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index d8c2c3a644c..83519821f79 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.93.0"] + "requirements": ["openai==1.93.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index bfff2521a61..d4d0ab8439a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,7 +1597,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.93.0 +openai==1.93.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 862043fbfdc..6775ace063f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1365,7 +1365,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.93.0 +openai==1.93.3 # homeassistant.components.openerz openerz-api==0.3.0 From ce5f06b1e545b6af9ed0c47467e8c4340b0c2446 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:43:02 +0200 Subject: [PATCH 0062/1118] Add new sensors to GIOS integration (#148510) --- homeassistant/components/gios/const.py | 2 + homeassistant/components/gios/icons.json | 3 + homeassistant/components/gios/sensor.py | 18 +++ homeassistant/components/gios/strings.json | 3 + tests/components/gios/fixtures/sensors.json | 14 +++ tests/components/gios/fixtures/station.json | 16 +++ .../gios/snapshots/test_diagnostics.ambr | 14 ++- .../gios/snapshots/test_sensor.ambr | 113 ++++++++++++++++++ 8 files changed, 181 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 2294e89c961..2d21b0b8d9e 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -19,6 +19,8 @@ API_TIMEOUT: Final = 30 ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" +ATTR_NO: Final = "no" +ATTR_NOX: Final = "nox" ATTR_NO2: Final = "no2" ATTR_O3: Final = "o3" ATTR_PM10: Final = "pm10" diff --git a/homeassistant/components/gios/icons.json b/homeassistant/components/gios/icons.json index e1d848e276b..2623ee1549d 100644 --- a/homeassistant/components/gios/icons.json +++ b/homeassistant/components/gios/icons.json @@ -13,6 +13,9 @@ "no2_index": { "default": "mdi:molecule" }, + "nox": { + "default": "mdi:molecule" + }, "o3_index": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 67997a01dc6..b8583adfcf1 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -27,7 +27,9 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, + ATTR_NO, ATTR_NO2, + ATTR_NOX, ATTR_O3, ATTR_PM10, ATTR_PM25, @@ -74,6 +76,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, translation_key="co", ), + GiosSensorEntityDescription( + key=ATTR_NO, + value=lambda sensors: sensors.no.value if sensors.no else None, + suggested_display_precision=0, + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_NO2, value=lambda sensors: sensors.no2.value if sensors.no2 else None, @@ -90,6 +100,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="no2_index", ), + GiosSensorEntityDescription( + key=ATTR_NOX, + translation_key=ATTR_NOX, + value=lambda sensors: sensors.nox.value if sensors.nox else None, + suggested_display_precision=0, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_O3, value=lambda sensors: sensors.o3.value if sensors.o3 else None, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index eca23159a13..d19edd63717 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -77,6 +77,9 @@ } } }, + "nox": { + "name": "Nitrogen oxides" + }, "o3_index": { "name": "Ozone index", "state": { diff --git a/tests/components/gios/fixtures/sensors.json b/tests/components/gios/fixtures/sensors.json index 0fe387d3126..64cb9685f97 100644 --- a/tests/components/gios/fixtures/sensors.json +++ b/tests/components/gios/fixtures/sensors.json @@ -20,6 +20,13 @@ { "Data": "2020-07-31 13:00:00", "Wartość": 251.097 } ] }, + "no": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.1 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.0 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5.2 } + ] + }, "no2": { "Lista danych pomiarowych": [ { "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 }, @@ -27,6 +34,13 @@ { "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 } ] }, + "nox": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.5 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 6.3 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.9 } + ] + }, "o3": { "Lista danych pomiarowych": [ { "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 }, diff --git a/tests/components/gios/fixtures/station.json b/tests/components/gios/fixtures/station.json index 167e4db3aee..1d112c0947b 100644 --- a/tests/components/gios/fixtures/station.json +++ b/tests/components/gios/fixtures/station.json @@ -23,6 +23,14 @@ "Wskaźnik - kod": "CO", "Id wskaźnika": 8 }, + { + "Identyfikator stanowiska": 664, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek azotu", + "Wskaźnik - wzór": "NO", + "Wskaźnik - kod": "NO", + "Id wskaźnika": 16 + }, { "Identyfikator stanowiska": 665, "Identyfikator stacji": 117, @@ -31,6 +39,14 @@ "Wskaźnik - kod": "NO2", "Id wskaźnika": 6 }, + { + "Identyfikator stanowiska": 666, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenki azotu", + "Wskaźnik - wzór": "NOx", + "Wskaźnik - kod": "NOx", + "Id wskaźnika": 7 + }, { "Identyfikator stanowiska": 667, "Identyfikator stacji": 117, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 4095bf8bf53..722d14e3681 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -42,14 +42,24 @@ 'name': 'carbon monoxide', 'value': 251.874, }), - 'no': None, + 'no': dict({ + 'id': 664, + 'index': None, + 'name': 'nitrogen monoxide', + 'value': 5.1, + }), 'no2': dict({ 'id': 665, 'index': 'good', 'name': 'nitrogen dioxide', 'value': 7.13411, }), - 'nox': None, + 'nox': dict({ + 'id': 666, + 'index': None, + 'name': 'nitrogen oxides', + 'value': 5.5, + }), 'o3': dict({ 'id': 667, 'index': 'good', diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index fd74cc222c8..2a0afcc72b1 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -302,6 +302,119 @@ 'state': 'good', }) # --- +# name: test_sensor[sensor.home_nitrogen_monoxide-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.home_nitrogen_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen monoxide', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-no', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'device_class': 'nitrogen_monoxide', + 'friendly_name': 'Home Nitrogen monoxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.1', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-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.home_nitrogen_oxides', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nitrogen oxides', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nox', + 'unique_id': '123-nox', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'friendly_name': 'Home Nitrogen oxides', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_oxides', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- # name: test_sensor[sensor.home_ozone-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 8aaf5756e022e043077acb484d5ba08ed690e779 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:44:50 +0200 Subject: [PATCH 0063/1118] Add workaround for sub units without main device in AVM Fritz!SmartHome (#148507) --- .../components/fritzbox/coordinator.py | 13 ++++-- tests/components/fritzbox/test_coordinator.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 8a37ebf63e4..a95af62da6c 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for device in new_data.devices.values(): # create device registry entry for new main devices - if ( - device.ain not in self.data.devices - and device.device_and_unit_id[1] is None + if device.ain not in self.data.devices and ( + device.device_and_unit_id[1] is None + or ( + # workaround for sub units without a main device, e.g. Energy 250 + # https://github.com/home-assistant/core/issues/145204 + device.device_and_unit_id[1] == "1" + and device.device_and_unit_id[0] not in new_data.devices + ) ): dr.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, name=device.name, - identifiers={(DOMAIN, device.ain)}, + identifiers={(DOMAIN, device.device_and_unit_id[0])}, manufacturer=device.manufacturer, model=device.productname, sw_version=device.fw_version, diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 61de0c99940..794d6ac4397 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock +from . import ( + FritzDeviceCoverMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, +) from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -140,3 +145,42 @@ async def test_coordinator_automatic_registry_cleanup( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 + + +async def test_coordinator_workaround_sub_units_without_main_device( + hass: HomeAssistant, + fritz: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the workaround for sub units without main device.""" + fritz().get_devices.return_value = [ + FritzDeviceSensorMock( + ain="bad_device-1", + device_and_unit_id=("bad_device", "1"), + name="bad_sensor_sub", + ), + FritzDeviceSensorMock( + ain="good_device", + device_and_unit_id=("good_device", None), + name="good_sensor", + ), + FritzDeviceSensorMock( + ain="good_device-1", + device_and_unit_id=("good_device", "1"), + name="good_sensor_sub", + ), + ] + + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 2 + assert device_entries[0].identifiers == {(DOMAIN, "good_device")} + assert device_entries[1].identifiers == {(DOMAIN, "bad_device")} From a7e879714b4da40f5cff1fd62fb35ff0e0ed2881 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:59:08 +0200 Subject: [PATCH 0064/1118] Add `water flow` sensor to IMGW PIB integration (#148517) --- homeassistant/components/imgw_pib/icons.json | 3 + homeassistant/components/imgw_pib/sensor.py | 11 +++- .../components/imgw_pib/strings.json | 3 + .../imgw_pib/snapshots/test_sensor.ambr | 57 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 29aa19a4b56..b9226276af6 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "water_flow": { + "default": "mdi:waves-arrow-right" + }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 7871006b2ae..1c49bfb2dc0 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -36,6 +36,15 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="water_flow", + translation_key="water_flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value=lambda data: data.water_flow.value, + ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9b7f132da6f..fc92ca573ab 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,9 @@ }, "entity": { "sensor": { + "water_flow": { + "name": "Water flow" + }, "water_level": { "name": "Water level" }, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 5b588af4518..97bb6eefef3 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_water_flow-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.river_name_station_name_water_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water flow', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_flow', + 'unique_id': '123_water_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'River Name (Station Name) Water flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From da255af8de97334c0261fae221d2742730281d2f Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:02:31 +0200 Subject: [PATCH 0065/1118] Bump aioautomower to 1.2.2 (#148497) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 046c20c1ddd..fb717a5615f 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==1.2.0"] + "requirements": ["aioautomower==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d4d0ab8439a..3fae8953386 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.0 +aioautomower==1.2.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6775ace063f..827b088fdab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.0 +aioautomower==1.2.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index d1e1f08f867..c58a12ad007 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,7 +63,8 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'messages': None, + 'messages': list([ + ]), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', From 330713244135bf90132944ab10ce0689adf688ca Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 9 Jul 2025 23:50:09 +0300 Subject: [PATCH 0066/1118] Jewish calendar: appropriate polling for sensors (2/3) (#144906) Co-authored-by: Joost Lekkerkerker --- .../components/jewish_calendar/entity.py | 4 +- .../components/jewish_calendar/sensor.py | 160 +++++++++++------- .../snapshots/test_diagnostics.ambr | 60 +------ 3 files changed, 100 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index b92d30048f0..9d713aad0eb 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -19,9 +19,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] class JewishCalendarDataResults: """Jewish Calendar results dataclass.""" - daytime_date: HDateInfo - after_shkia_date: HDateInfo - after_tzais_date: HDateInfo + dateinfo: HDateInfo zmanim: Zmanim diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 91c618e1c1c..6479a61c713 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -16,10 +16,10 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.const import EntityCategory +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import dt as dt_util from .entity import ( @@ -37,15 +37,19 @@ class JewishCalendarBaseSensorDescription(SensorEntityDescription): """Base class describing Jewish Calendar sensor entities.""" value_fn: Callable | None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None @dataclass(frozen=True, kw_only=True) class JewishCalendarSensorDescription(JewishCalendarBaseSensorDescription): """Class describing Jewish Calendar sensor entities.""" - value_fn: Callable[[JewishCalendarDataResults], str | int] - attr_fn: Callable[[JewishCalendarDataResults], dict[str, str]] | None = None + value_fn: Callable[[HDateInfo], str | int] + attr_fn: Callable[[HDateInfo], dict[str, str]] | None = None options_fn: Callable[[bool], list[str]] | None = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = ( + lambda zmanim: zmanim.shkia.local + ) @dataclass(frozen=True, kw_only=True) @@ -55,17 +59,18 @@ class JewishCalendarTimestampSensorDescription(JewishCalendarBaseSensorDescripti value_fn: ( Callable[[HDateInfo, Callable[[dt.date], Zmanim]], dt.datetime | None] | None ) = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = None INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( JewishCalendarSensorDescription( key="date", translation_key="hebrew_date", - value_fn=lambda results: str(results.after_shkia_date.hdate), - attr_fn=lambda results: { - "hebrew_year": str(results.after_shkia_date.hdate.year), - "hebrew_month_name": str(results.after_shkia_date.hdate.month), - "hebrew_day": str(results.after_shkia_date.hdate.day), + value_fn=lambda info: str(info.hdate), + attr_fn=lambda info: { + "hebrew_year": str(info.hdate.year), + "hebrew_month_name": str(info.hdate.month), + "hebrew_day": str(info.hdate.day), }, ), JewishCalendarSensorDescription( @@ -73,24 +78,19 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, + value_fn=lambda info: info.upcoming_shabbat.parasha, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarSensorDescription( key="holiday", translation_key="holiday", device_class=SensorDeviceClass.ENUM, options_fn=lambda diaspora: HolidayDatabase(diaspora).get_all_names(), - value_fn=lambda results: ", ".join( - str(holiday) for holiday in results.after_shkia_date.holidays - ), - attr_fn=lambda results: { - "id": ", ".join( - holiday.name for holiday in results.after_shkia_date.holidays - ), + value_fn=lambda info: ", ".join(str(holiday) for holiday in info.holidays), + attr_fn=lambda info: { + "id": ", ".join(holiday.name for holiday in info.holidays), "type": ", ".join( - dict.fromkeys( - _holiday.type.name for _holiday in results.after_shkia_date.holidays - ) + dict.fromkeys(_holiday.type.name for _holiday in info.holidays) ), }, ), @@ -98,13 +98,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: results.after_shkia_date.omer.total_days, + value_fn=lambda info: info.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: results.daytime_date.daf_yomi, + value_fn=lambda info: info.daf_yomi, ), ) @@ -184,12 +184,14 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_shabbat_havdalah", translation_key="upcoming_shabbat_havdalah", entity_registry_enabled_default=False, value_fn=lambda at_date, mz: mz(at_date.upcoming_shabbat.gdate).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_candle_lighting", @@ -197,6 +199,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_havdalah", @@ -204,6 +207,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.last_day.gdate ).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), ) @@ -227,46 +231,79 @@ async def async_setup_entry( class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): """Base class for Jewish calendar sensors.""" + _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC + _update_unsub: CALLBACK_TYPE | None = None - async def async_update(self) -> None: - """Update the state of the sensor.""" + entity_description: JewishCalendarBaseSensorDescription + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" now = dt_util.now() + zmanim = self.make_zmanim(now.date()) + update = None + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(zmanim) + next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) + if update is None or now > update: + update = next_midnight + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update_data, update + ) + + @callback + def _update_data(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.create_results(now) + self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) today = now.date() - event_date = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, today) + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) - if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) - return + def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: + """Get the next date info.""" + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" - sunset = dt_util.as_local(event_date) + if now is None: + now = dt_util.now() - _LOGGER.debug("Now: %s Sunset: %s", now, sunset) + today = now.date() + zmanim = self.make_zmanim(today) + update = None + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(zmanim) - daytime_date = HDateInfo(today, diaspora=self.data.diaspora) - - # The Jewish day starts after darkness (called "tzais") and finishes at - # sunset ("shkia"). The time in between is a gray area - # (aka "Bein Hashmashot" # codespell:ignore - # - literally: "in between the sun and the moon"). - - # For some sensors, it is more interesting to consider the date to be - # tomorrow based on sunset ("shkia"), for others based on "tzais". - # Hence the following variables. - after_tzais_date = after_shkia_date = daytime_date - today_times = self.make_zmanim(today) - - if now > sunset: - after_shkia_date = daytime_date.next_day - - if today_times.havdalah and now > today_times.havdalah: - after_tzais_date = daytime_date.next_day - - self.data.results = JewishCalendarDataResults( - daytime_date, after_shkia_date, after_tzais_date, today_times - ) + _LOGGER.debug("Today: %s, update: %s", today, update) + if update is not None and now >= update: + return self.data.results.dateinfo.next_day + return self.data.results.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -288,18 +325,14 @@ class JewishCalendarSensor(JewishCalendarBaseSensor): @property def native_value(self) -> str | int | dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - return None - return self.entity_description.value_fn(self.data.results) + return self.entity_description.value_fn(self.get_dateinfo()) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self.data.results is None: + if self.entity_description.attr_fn is None: return {} - if self.entity_description.attr_fn is not None: - return self.entity_description.attr_fn(self.data.results) - return {} + return self.entity_description.attr_fn(self.get_dateinfo()) class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @@ -312,9 +345,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" if self.data.results is None: - return None + self.create_results() + assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: return self.data.results.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn( - self.data.results.after_tzais_date, self.make_zmanim - ) + return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 3c8acde6e72..0a392e101c5 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -18,25 +18,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, @@ -92,25 +74,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, @@ -166,25 +130,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, From 15544769b68e3c0282c6f45f185501df735f16e0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 9 Jul 2025 23:08:24 +0200 Subject: [PATCH 0067/1118] Add action for activity reactions to Bring! (#138175) --- homeassistant/components/bring/__init__.py | 13 ++ homeassistant/components/bring/const.py | 5 +- homeassistant/components/bring/icons.json | 3 + homeassistant/components/bring/services.py | 110 +++++++++++ homeassistant/components/bring/services.yaml | 25 +++ homeassistant/components/bring/strings.json | 27 +++ tests/components/bring/test_services.py | 190 +++++++++++++++++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/bring/services.py create mode 100644 tests/components/bring/test_services.py diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6c0b34c66f0..943b4863aac 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -8,20 +8,33 @@ from bring_api import Bring from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import ( BringActivityCoordinator, BringConfigEntry, BringCoordinators, BringDataUpdateCoordinator, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Bring! services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py index 911c08a835d..f8a10d5c26b 100644 --- a/homeassistant/components/bring/const.py +++ b/homeassistant/components/bring/const.py @@ -7,5 +7,8 @@ DOMAIN = "bring" ATTR_SENDER: Final = "sender" ATTR_ITEM_NAME: Final = "item" ATTR_NOTIFICATION_TYPE: Final = "message" - +ATTR_REACTION: Final = "reaction" +ATTR_ACTIVITY: Final = "uuid" +ATTR_RECEIVER: Final = "publicUserUuid" SERVICE_PUSH_NOTIFICATION = "send_message" +SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction" diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index ea4f4e877bc..288921c41b4 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -35,6 +35,9 @@ "services": { "send_message": { "service": "mdi:cellphone-message" + }, + "send_reaction": { + "service": "mdi:thumb-up" } } } diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py new file mode 100644 index 00000000000..e648fcdd2f1 --- /dev/null +++ b/homeassistant/components/bring/services.py @@ -0,0 +1,110 @@ +"""Actions for Bring! integration.""" + +import logging +from typing import TYPE_CHECKING + +from bring_api import ( + ActivityType, + BringAuthException, + BringNotificationType, + BringRequestException, + ReactionType, +) +import voluptuous as vol + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er + +from .const import ( + ATTR_ACTIVITY, + ATTR_REACTION, + ATTR_RECEIVER, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from .coordinator import BringConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_REACTION): vol.All( + vol.Upper, + vol.Coerce(ReactionType), + ), + } +) + + +def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry: + """Return config entry or raise if not found or not loaded.""" + entry = hass.config_entries.async_get_entry(entry_id) + if TYPE_CHECKING: + assert entry + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Bring! integration.""" + + async def async_send_activity_stream_reaction(call: ServiceCall) -> None: + """Send a reaction in response to recent activity of a list member.""" + + if ( + not (state := hass.states.get(call.data[ATTR_ENTITY_ID])) + or not (entity := er.async_get(hass).async_get(call.data[ATTR_ENTITY_ID])) + or not entity.config_entry_id + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ + ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID], + }, + ) + config_entry = get_config_entry(hass, entity.config_entry_id) + + coordinator = config_entry.runtime_data.data + + list_uuid = entity.unique_id.split("_")[1] + + activity = state.attributes[ATTR_EVENT_TYPE] + + reaction: ReactionType = call.data[ATTR_REACTION] + + if not activity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="activity_not_found", + ) + try: + await coordinator.bring.notify( + list_uuid, + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver=state.attributes[ATTR_RECEIVER], + activity=state.attributes[ATTR_ACTIVITY], + activity_type=ActivityType(activity.upper()), + reaction=reaction, + ) + except (BringRequestException, BringAuthException) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reaction_request_failed", + ) from e + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + async_send_activity_stream_reaction, + SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA, + ) diff --git a/homeassistant/components/bring/services.yaml b/homeassistant/components/bring/services.yaml index 98d5c68de13..087b12604a9 100644 --- a/homeassistant/components/bring/services.yaml +++ b/homeassistant/components/bring/services.yaml @@ -21,3 +21,28 @@ send_message: required: false selector: text: +send_reaction: + fields: + entity_id: + required: true + selector: + entity: + filter: + - integration: bring + domain: event + example: event.shopping_list + reaction: + required: true + selector: + select: + options: + - label: 👍🏼 + value: thumbs_up + - label: 🧐 + value: monocle + - label: 🤤 + value: drooling + - label: ❤️ + value: heart + mode: dropdown + example: thumbs_up diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 2c30af5adce..48677d52523 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -144,6 +144,19 @@ }, "notify_request_failed": { "message": "Failed to send push notification for Bring! due to a connection error, try again later" + }, + "reaction_request_failed": { + "message": "Failed to send reaction for Bring! due to a connection error, try again later" + }, + "activity_not_found": { + "message": "Failed to send reaction for Bring! — No recent activity found" + }, + "entity_not_found": { + "message": "Failed to send reaction for Bring! — Unknown entity {entity_id}" + }, + + "entry_not_loaded": { + "message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant." } }, "services": { @@ -164,6 +177,20 @@ "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } + }, + "send_reaction": { + "name": "Send reaction", + "description": "Sends a reaction to a recent activity on a Bring! list by a member of the shared list.", + "fields": { + "entity_id": { + "name": "Activities", + "description": "Select the Bring! activities event entity for reacting to its most recent event" + }, + "reaction": { + "name": "Reaction", + "description": "Type of reaction to send in response." + } + } } }, "selector": { diff --git a/tests/components/bring/test_services.py b/tests/components/bring/test_services.py new file mode 100644 index 00000000000..d010c2b86a0 --- /dev/null +++ b/tests/components/bring/test_services.py @@ -0,0 +1,190 @@ +"""Test actions of Bring! integration.""" + +from unittest.mock import AsyncMock + +from bring_api import ( + ActivityType, + BringActivityResponse, + BringNotificationType, + BringRequestException, + ReactionType, +) +import pytest + +from homeassistant.components.bring.const import ( + ATTR_REACTION, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("reaction", "call_arg"), + [ + ("drooling", ReactionType.DROOLING), + ("heart", ReactionType.HEART), + ("monocle", ReactionType.MONOCLE), + ("thumbs_up", ReactionType.THUMBS_UP), + ], +) +async def test_send_reaction( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + reaction: str, + call_arg: ReactionType, +) -> None: + """Test send activity stream reaction.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: reaction, + }, + blocking=True, + ) + + mock_bring_client.notify.assert_called_once_with( + "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver="9a21fdfc-63a4-441a-afc1-ef3030605a9d", + activity="673594a9-f92d-4cb6-adf1-d2f7a83207a4", + activity_type=ActivityType.LIST_ITEMS_CHANGED, + reaction=call_arg, + ) + + +async def test_send_reaction_exception( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction with exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + mock_bring_client.notify.side_effect = BringRequestException + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! due to a connection error, try again later", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_config_entry_not_loaded( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, +) -> None: + """Test send activity stream reaction config entry not loaded exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(bring_config_entry.entry_id) + + assert bring_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises( + ServiceValidationError, + match="The account associated with this Bring! list is either not loaded or disabled in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_unknown_entity( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test send activity stream reaction unknown entity exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + entity_registry.async_update_entity( + "event.einkauf_activities", disabled_by=er.RegistryEntryDisabler.USER + ) + with pytest.raises( + ServiceValidationError, + match="Failed to send reaction for Bring! — Unknown entity event.einkauf_activities", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +async def test_send_reaction_not_found( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction not found validation error.""" + mock_bring_client.get_activity.return_value = BringActivityResponse.from_dict( + {"timeline": [], "timestamp": "2025-01-01T03:09:33.036Z", "totalEvents": 0} + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! — No recent activity found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) From a4b9efa1b10d042bc3af6a184dfefad12afb8714 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:23:04 -0500 Subject: [PATCH 0068/1118] Support AM/FM channel name in Russound RIO (#148421) --- homeassistant/components/russound_rio/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 29944de09b0..a4b86a85e94 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -132,7 +132,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - return self._source.song_name + return self._source.song_name or self._source.channel @property def media_artist(self) -> str | None: From 24a7ebd2bb9e288b086dd0014eb8da21f8a8b156 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 9 Jul 2025 23:51:40 +0200 Subject: [PATCH 0069/1118] Move KNXModule class to separate module (#146100) --- homeassistant/components/knx/__init__.py | 302 +----------------- homeassistant/components/knx/binary_sensor.py | 4 +- homeassistant/components/knx/button.py | 4 +- homeassistant/components/knx/climate.py | 4 +- homeassistant/components/knx/const.py | 2 +- homeassistant/components/knx/cover.py | 4 +- homeassistant/components/knx/date.py | 4 +- homeassistant/components/knx/datetime.py | 4 +- homeassistant/components/knx/device.py | 2 +- .../components/knx/device_trigger.py | 2 +- homeassistant/components/knx/diagnostics.py | 2 +- homeassistant/components/knx/entity.py | 4 +- homeassistant/components/knx/expose.py | 2 +- homeassistant/components/knx/fan.py | 4 +- homeassistant/components/knx/knx_module.py | 301 +++++++++++++++++ homeassistant/components/knx/light.py | 4 +- homeassistant/components/knx/notify.py | 4 +- homeassistant/components/knx/number.py | 4 +- homeassistant/components/knx/scene.py | 4 +- homeassistant/components/knx/select.py | 4 +- homeassistant/components/knx/sensor.py | 4 +- homeassistant/components/knx/services.py | 2 +- .../components/knx/storage/__init__.py | 2 +- .../knx/storage/entity_store_validation.py | 2 +- homeassistant/components/knx/switch.py | 4 +- homeassistant/components/knx/text.py | 4 +- homeassistant/components/knx/time.py | 4 +- homeassistant/components/knx/trigger.py | 2 +- homeassistant/components/knx/weather.py | 4 +- homeassistant/components/knx/websocket.py | 2 +- tests/components/knx/test_events.py | 3 +- tests/components/knx/test_expose.py | 2 +- 32 files changed, 361 insertions(+), 339 deletions(-) create mode 100644 homeassistant/components/knx/knx_module.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 470f7891292..6fa4c8146ba 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -1,34 +1,17 @@ -"""Support KNX devices.""" +"""The KNX integration.""" from __future__ import annotations import contextlib -import logging from pathlib import Path from typing import Final import voluptuous as vol -from xknx import XKNX -from xknx.core import XknxConnectionState -from xknx.core.state_updater import StateTrackerType, TrackerOptions -from xknx.core.telegram_queue import TelegramQueue -from xknx.dpt import DPTBase -from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException -from xknx.io import ConnectionConfig, ConnectionType, SecureConfig -from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress -from xknx.telegram.apci import GroupValueResponse, GroupValueWrite +from xknx.exceptions import XKNXException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_PORT, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config @@ -36,40 +19,17 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_KNX_CONNECTION_TYPE, CONF_KNX_EXPOSE, - CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_KNXKEY_FILENAME, - CONF_KNX_KNXKEY_PASSWORD, - CONF_KNX_LOCAL_IP, - CONF_KNX_MCAST_GRP, - CONF_KNX_MCAST_PORT, - CONF_KNX_RATE_LIMIT, - CONF_KNX_ROUTE_BACK, - CONF_KNX_ROUTING, - CONF_KNX_ROUTING_BACKBONE_KEY, - CONF_KNX_ROUTING_SECURE, - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, - CONF_KNX_SECURE_DEVICE_AUTHENTICATION, - CONF_KNX_SECURE_USER_ID, - CONF_KNX_SECURE_USER_PASSWORD, - CONF_KNX_STATE_UPDATER, - CONF_KNX_TELEGRAM_LOG_SIZE, - CONF_KNX_TUNNEL_ENDPOINT_IA, - CONF_KNX_TUNNELING, - CONF_KNX_TUNNELING_TCP, - CONF_KNX_TUNNELING_TCP_SECURE, DATA_HASS_CONFIG, DOMAIN, - KNX_ADDRESS, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI, SUPPORTED_PLATFORMS_YAML, - TELEGRAM_LOG_DEFAULT, ) -from .device import KNXInterfaceDevice -from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure -from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject +from .expose import create_knx_exposure +from .knx_module import KNXModule +from .project import STORAGE_KEY as PROJECT_STORAGE_KEY from .schema import ( BinarySensorSchema, ButtonSchema, @@ -92,12 +52,10 @@ from .schema import ( WeatherSchema, ) from .services import async_setup_services -from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore -from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY +from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY from .websocket import register_panel -_LOGGER = logging.getLogger(__name__) - _KNX_YAML_CONFIG: Final = "knx_yaml_config" CONFIG_SCHEMA = vol.Schema( @@ -162,6 +120,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[KNX_MODULE_KEY] = knx_module + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( @@ -255,243 +215,3 @@ async def async_remove_config_entry_device( if entity.device_id == device_entry.id: await knx_module.config_store.delete_entity(entity.entity_id) return True - - -class KNXModule: - """Representation of KNX Object.""" - - def __init__( - self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry - ) -> None: - """Initialize KNX module.""" - self.hass = hass - self.config_yaml = config - self.connected = False - self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] - self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} - self.entry = entry - - self.project = KNXProject(hass=hass, entry=entry) - self.config_store = KNXConfigStore(hass=hass, config_entry=entry) - - default_state_updater = ( - TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) - if self.entry.data[CONF_KNX_STATE_UPDATER] - else TrackerOptions( - tracker_type=StateTrackerType.INIT, update_interval_min=60 - ) - ) - self.xknx = XKNX( - address_format=self.project.get_address_format(), - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=default_state_updater, - ) - self.xknx.connection_manager.register_connection_state_changed_cb( - self.connection_state_changed_cb - ) - self.telegrams = Telegrams( - hass=hass, - xknx=self.xknx, - project=self.project, - log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), - ) - self.interface_device = KNXInterfaceDevice( - hass=hass, entry=entry, xknx=self.xknx - ) - - self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() - - self.entry.async_on_unload( - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - ) - self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - - async def start(self) -> None: - """Start XKNX object. Connect to tunneling or Routing device.""" - await self.project.load_project(self.xknx) - await self.config_store.load_data() - await self.telegrams.load_history() - await self.xknx.start() - - async def stop(self, event: Event | None = None) -> None: - """Stop XKNX object. Disconnect from tunneling or Routing device.""" - await self.xknx.stop() - await self.telegrams.save_history() - - def connection_config(self) -> ConnectionConfig: - """Return the connection_config.""" - _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] - _knxkeys_file: str | None = ( - self.hass.config.path( - STORAGE_DIR, - self.entry.data[CONF_KNX_KNXKEY_FILENAME], - ) - if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None - else None - ) - if _conn_type == CONF_KNX_ROUTING: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP_SECURE, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - secure_config=SecureConfig( - user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), - user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), - device_authentication_password=self.entry.data.get( - CONF_KNX_SECURE_DEVICE_AUTHENTICATION - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - if _conn_type == CONF_KNX_ROUTING_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING_SECURE, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - secure_config=SecureConfig( - backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), - latency_ms=self.entry.data.get( - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - return ConnectionConfig( - auto_reconnect=True, - individual_address=self.entry.data.get( - CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload - ), - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - - def connection_state_changed_cb(self, state: XknxConnectionState) -> None: - """Call invoked after a KNX connection state change was received.""" - self.connected = state == XknxConnectionState.CONNECTED - for device in self.xknx.devices: - device.after_update() - - def telegram_received_cb(self, telegram: Telegram) -> None: - """Call invoked after a KNX telegram was received.""" - # Not all telegrams have serializable data. - data: int | tuple[int, ...] | None = None - value = None - if ( - isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) - and telegram.payload.value is not None - and isinstance( - telegram.destination_address, (GroupAddress, InternalGroupAddress) - ) - ): - data = telegram.payload.value.value - if transcoder := ( - self.group_address_transcoder.get(telegram.destination_address) - or next( - ( - _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() - if _filter.match(telegram.destination_address) - ), - None, - ) - ): - try: - value = transcoder.from_knx(telegram.payload.value) - except (ConversionError, CouldNotParseTelegram) as err: - _LOGGER.warning( - ( - "Error in `knx_event` at decoding type '%s' from" - " telegram %s\n%s" - ), - transcoder.__name__, - telegram, - err, - ) - - self.hass.bus.async_fire( - "knx_event", - { - "data": data, - "destination": str(telegram.destination_address), - "direction": telegram.direction.value, - "value": value, - "source": str(telegram.source_address), - "telegramtype": telegram.payload.__class__.__name__, - }, - ) - - def register_event_callback(self) -> TelegramQueue.Callback: - """Register callback for knx_event within XKNX TelegramQueue.""" - address_filters = [] - for filter_set in self.config_yaml[CONF_EVENT]: - _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) - address_filters.extend(_filters) - if (dpt := filter_set.get(CONF_TYPE)) and ( - transcoder := DPTBase.parse_transcoder(dpt) - ): - self._address_filter_transcoder.update( - dict.fromkeys(_filters, transcoder) - ) - - return self.xknx.telegram_queue.register_telegram_received_cb( - self.telegram_received_cb, - address_filters=address_filters, - group_addresses=[], - match_for_outgoing=True, - ) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 1bad8bafdf0..947d382a12c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP binary sensors.""" +"""Support for KNX binary sensor entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( ATTR_COUNTER, ATTR_SOURCE, @@ -39,6 +38,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .storage.const import CONF_ENTITY, CONF_GA_SENSOR from .storage.util import ConfigExtractor diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 538299a0556..2c2baa3a218 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP buttons.""" +"""Support for KNX button entities.""" from __future__ import annotations @@ -11,9 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index fdce5e0c470..f59d48de629 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP climate devices.""" +"""Support for KNX climate entities.""" from __future__ import annotations @@ -37,9 +37,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import ClimateSchema ATTR_COMMAND_VALUE = "command_value" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ce79b4ca7a..dbc02f08245 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule DOMAIN: Final = "knx" KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index f5d482b9d14..ef7084661f1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP covers.""" +"""Support for KNX cover entities.""" from __future__ import annotations @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import CoverSchema from .storage.const import ( CONF_ENTITY, diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 7980e6a2bc3..a4fc8d276bc 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP date.""" +"""Support for KNX date entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 7701597a8ef..04d04527241 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP datetime.""" +"""Support for KNX datetime entities.""" from __future__ import annotations @@ -23,7 +23,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -32,6 +31,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index b43b5926d86..44fa7163360 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -1,4 +1,4 @@ -"""Handle KNX Devices.""" +"""Handle Home Assistant Devices for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 2eb1f86e7fc..e4a48c9c68d 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device triggers for KNX.""" +"""Provide device triggers for KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 974a6b3b448..6d523dda0f5 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for KNX.""" +"""Diagnostics support for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index a042c2b4c6b..c4379bcf869 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -1,4 +1,4 @@ -"""Base class for KNX devices.""" +"""Base classes for KNX entities.""" from __future__ import annotations @@ -17,7 +17,7 @@ from .storage.config_store import PlatformControllerBase from .storage.const import CONF_DEVICE_INFO if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule class KnxUiEntityPlatformController(PlatformControllerBase): diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 461e6f25879..0a42b6018ba 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,4 +1,4 @@ -"""Exposures to KNX bus.""" +"""Expose Home Assistant entity states to KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 926b6458706..23f25dc8469 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP fans.""" +"""Support for KNX fan entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py new file mode 100644 index 00000000000..8974cad1baa --- /dev/null +++ b/homeassistant/components/knx/knx_module.py @@ -0,0 +1,301 @@ +"""Base module for the KNX integration.""" + +from __future__ import annotations + +import logging + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions +from xknx.core.telegram_queue import TelegramQueue +from xknx.dpt import DPTBase +from xknx.exceptions import ConversionError, CouldNotParseTelegram +from xknx.io import ConnectionConfig, ConnectionType, SecureConfig +from xknx.telegram import AddressFilter, Telegram +from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_PORT, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_KNXKEY_FILENAME, + CONF_KNX_KNXKEY_PASSWORD, + CONF_KNX_LOCAL_IP, + CONF_KNX_MCAST_GRP, + CONF_KNX_MCAST_PORT, + CONF_KNX_RATE_LIMIT, + CONF_KNX_ROUTE_BACK, + CONF_KNX_ROUTING, + CONF_KNX_ROUTING_BACKBONE_KEY, + CONF_KNX_ROUTING_SECURE, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_SECURE_USER_ID, + CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, + CONF_KNX_TUNNEL_ENDPOINT_IA, + CONF_KNX_TUNNELING, + CONF_KNX_TUNNELING_TCP, + CONF_KNX_TUNNELING_TCP_SECURE, + KNX_ADDRESS, + TELEGRAM_LOG_DEFAULT, +) +from .device import KNXInterfaceDevice +from .expose import KNXExposeSensor, KNXExposeTime +from .project import KNXProject +from .storage.config_store import KNXConfigStore +from .telegrams import Telegrams + +_LOGGER = logging.getLogger(__name__) + + +class KNXModule: + """Representation of KNX Object.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry + ) -> None: + """Initialize KNX module.""" + self.hass = hass + self.config_yaml = config + self.connected = False + self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] + self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} + self.entry = entry + + self.project = KNXProject(hass=hass, entry=entry) + self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) + self.xknx = XKNX( + address_format=self.project.get_address_format(), + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=default_state_updater, + ) + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + self.telegrams = Telegrams( + hass=hass, + xknx=self.xknx, + project=self.project, + log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), + ) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) + + self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() + + self.entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + ) + + async def start(self) -> None: + """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project(self.xknx) + await self.config_store.load_data() + await self.telegrams.load_history() + await self.xknx.start() + + async def stop(self, event: Event | None = None) -> None: + """Stop XKNX object. Disconnect from tunneling or Routing device.""" + await self.xknx.stop() + await self.telegrams.save_history() + + def connection_config(self) -> ConnectionConfig: + """Return the connection_config.""" + _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] + _knxkeys_file: str | None = ( + self.hass.config.path( + STORAGE_DIR, + self.entry.data[CONF_KNX_KNXKEY_FILENAME], + ) + if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None + else None + ) + if _conn_type == CONF_KNX_ROUTING: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP_SECURE, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + secure_config=SecureConfig( + user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), + user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), + device_authentication_password=self.entry.data.get( + CONF_KNX_SECURE_DEVICE_AUTHENTICATION + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + if _conn_type == CONF_KNX_ROUTING_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING_SECURE, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + secure_config=SecureConfig( + backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), + latency_ms=self.entry.data.get( + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + return ConnectionConfig( + auto_reconnect=True, + individual_address=self.entry.data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload + ), + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + self.connected = state == XknxConnectionState.CONNECTED + for device in self.xknx.devices: + device.after_update() + + def telegram_received_cb(self, telegram: Telegram) -> None: + """Call invoked after a KNX telegram was received.""" + # Not all telegrams have serializable data. + data: int | tuple[int, ...] | None = None + value = None + if ( + isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) + and telegram.payload.value is not None + and isinstance( + telegram.destination_address, (GroupAddress, InternalGroupAddress) + ) + ): + data = telegram.payload.value.value + if transcoder := ( + self.group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(telegram.payload.value) + except (ConversionError, CouldNotParseTelegram) as err: + _LOGGER.warning( + ( + "Error in `knx_event` at decoding type '%s' from" + " telegram %s\n%s" + ), + transcoder.__name__, + telegram, + err, + ) + + self.hass.bus.async_fire( + "knx_event", + { + "data": data, + "destination": str(telegram.destination_address), + "direction": telegram.direction.value, + "value": value, + "source": str(telegram.source_address), + "telegramtype": telegram.payload.__class__.__name__, + }, + ) + + def register_event_callback(self) -> TelegramQueue.Callback: + """Register callback for knx_event within XKNX TelegramQueue.""" + address_filters = [] + for filter_set in self.config_yaml[CONF_EVENT]: + _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) + address_filters.extend(_filters) + if (dpt := filter_set.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + self._address_filter_transcoder.update( + dict.fromkeys(_filters, transcoder) + ) + + return self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, + address_filters=address_filters, + group_addresses=[], + match_for_outgoing=True, + ) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index ff0f4538089..cbecb878e12 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP lights.""" +"""Support for KNX light entities.""" from __future__ import annotations @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import color as color_util -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( CONF_COLOR_TEMP_MAX, diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 97980ab3d36..d64bac80d9d 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP notifications.""" +"""Support for KNX notify entities.""" from __future__ import annotations @@ -12,9 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 67e8778accc..30efb5e01ee 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP numeric values.""" +"""Support for KNX number entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import NumberSchema diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index f5361a6e7da..39e627ca8ff 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -1,4 +1,4 @@ -"""Support for KNX scenes.""" +"""Support for KNX scene entities.""" from __future__ import annotations @@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SceneSchema diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index e80fa66f9d4..0dc2584876d 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP select entities.""" +"""Support for KNX select entities.""" from __future__ import annotations @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, @@ -30,6 +29,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SelectSchema diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 8e537ea234e..e75d1f180e2 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP sensors.""" +"""Support for KNX sensor entities.""" from __future__ import annotations @@ -33,9 +33,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum -from . import KNXModule from .const import ATTR_SOURCE, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 04803e140fd..f63612f97ef 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -35,7 +35,7 @@ from .expose import create_knx_exposure from .schema import ExposeSchema, dpt_base_type_validator, ga_validator if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/knx/storage/__init__.py b/homeassistant/components/knx/storage/__init__.py index 25d84406d03..a588a3d154e 100644 --- a/homeassistant/components/knx/storage/__init__.py +++ b/homeassistant/components/knx/storage/__init__.py @@ -1 +1 @@ -"""Helpers for KNX.""" +"""Handle persistent storage for the KNX integration.""" diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index 9bad5297853..1da7b58378d 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -1,4 +1,4 @@ -"""KNX Entity Store Validation.""" +"""KNX entity store validation.""" from typing import Literal, TypedDict diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 5a01457d8d3..4d6ca288dc6 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP switches.""" +"""Support for KNX switch entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_INVERT, CONF_RESPOND_TO_READ, @@ -35,6 +34,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import SwitchSchema from .storage.const import CONF_ENTITY, CONF_GA_SWITCH from .storage.util import ConfigExtractor diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 9c2bb88f92b..14c9af11ad3 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP text.""" +"""Support for KNX text entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 2c74ab18af3..3bc171cae31 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP time.""" +"""Support for KNX time entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py index ae3ba088357..ba8bfff5d3b 100644 --- a/homeassistant/components/knx/trigger.py +++ b/homeassistant/components/knx/trigger.py @@ -1,4 +1,4 @@ -"""Offer knx telegram automation triggers.""" +"""Provide KNX automation triggers.""" from typing import Final diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 342ab445611..e8f0036f5bb 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP weather station.""" +"""Support for KNX weather entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import WeatherSchema diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 9ba3e0ccff6..31c5e8297e0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -36,7 +36,7 @@ from .storage.entity_store_validation import ( from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule URL_BASE: Final = "/knx_static" diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index 2228781ba89..a40109d167e 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -4,7 +4,8 @@ import logging import pytest -from homeassistant.components.knx import CONF_EVENT, CONF_TYPE, KNX_ADDRESS +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.const import CONF_EVENT, CONF_TYPE from homeassistant.core import HomeAssistant from .conftest import KNXTestKit diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index f7a3f4e94f2..331678f0683 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS +from homeassistant.components.knx.const import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import ( CONF_ATTRIBUTE, From 49baa65f61af5631c512de9134920a69d749b7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 00:26:13 +0200 Subject: [PATCH 0070/1118] Add Home Connect resume command button when an appliance is paused (#148512) --- .../components/home_connect/coordinator.py | 30 ++++++++- tests/components/home_connect/test_button.py | 63 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3c9d33424a8..76faaefa931 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -41,7 +41,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import ( + API_DEFAULT_RETRY_AFTER, + APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_PAUSE, + DOMAIN, +) from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -66,6 +71,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.clear() self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected @@ -201,6 +207,28 @@ class HomeConnectCoordinator( raw_key=status_key.value, value=event.value, ) + if ( + status_key == StatusKey.BSH_COMMON_OPERATION_STATE + and event.value == BSH_OPERATION_STATE_PAUSE + and CommandKey.BSH_COMMON_RESUME_PROGRAM + not in ( + commands := self.data[ + event_message_ha_id + ].commands + ) + ): + # All the appliances that can be paused + # should have the resume command available. + commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM) + for ( + listener, + context, + ) in self._special_listeners.values(): + if ( + EventKey.BSH_COMMON_APPLIANCE_DEPAIRED + not in context + ): + listener() self._call_event_listener(event_message) case EventType.NOTIFY: diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index ee4d5f1d729..e61ec5e2b1f 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -1,12 +1,14 @@ """Tests for home_connect button entities.""" from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfCommands, CommandKey, + Event, + EventKey, EventMessage, HomeAppliance, ) @@ -317,3 +319,62 @@ async def test_stop_program_button_exception( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_enable_resume_command_on_pause( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test if all commands enabled option works as expected.""" + entity_id = "button.washer_resume_program" + + original_get_available_commands = client.get_available_commands + + async def get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + array_of_commands = cast( + ArrayOfCommands, await original_get_available_commands(ha_id) + ) + if ha_id == appliance.ha_id: + for command in array_of_commands.commands: + if command.key == CommandKey.BSH_COMMON_RESUME_PROGRAM: + # Simulate that the resume command is not available initially + array_of_commands.commands.remove(command) + break + return array_of_commands + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE.value, + timestamp=0, + level="", + handling="", + value="BSH.Common.EnumType.OperationState.Pause", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) From c2bc4a990eb3aafce6d60a613f4f67a081cedb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 09:35:30 +0200 Subject: [PATCH 0071/1118] Use the link to the issue instead of creating new issues at Home Connect (#148523) --- homeassistant/components/home_connect/coordinator.py | 5 +---- homeassistant/components/home_connect/strings.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 76faaefa931..bb419f6bd7c 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -655,10 +655,7 @@ class HomeConnectCoordinator( "times": str(MAX_EXECUTIONS), "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_new_issue_url": ( - "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" - f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" - ), + "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", }, ) return True diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 99c89ec8788..e1c0b42ca0b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -130,7 +130,7 @@ "step": { "confirm": { "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." } } } From cbe2fbdc34d419d9f22435a3b93fafad0dba6e0b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 10 Jul 2025 15:46:10 +0700 Subject: [PATCH 0072/1118] Encrypted reasoning items support for OpenAI Conversation (#148279) --- .../components/openai_conversation/entity.py | 4 ++-- .../components/openai_conversation/conftest.py | 18 +++++++++++++++++- .../openai_conversation/test_conversation.py | 6 ++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 69ca4c9a1eb..7351cbccbfa 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -293,6 +293,7 @@ class OpenAIBaseLLMEntity(Entity): "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, + "store": False, "stream": True, } if tools: @@ -304,8 +305,7 @@ class OpenAIBaseLLMEntity(Entity): CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) } - else: - model_args["store"] = False + model_args["include"] = ["reasoning.encrypted_content"] try: result = await client.responses.create(**model_args) diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index b8944d837be..628c1846e16 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -5,7 +5,10 @@ from unittest.mock import patch import pytest -from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME +from homeassistant.components.openai_conversation.const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -59,6 +62,19 @@ def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_reasoning_model( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "o4-mini"}, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 3d662cb0f00..7a3bcb21768 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -499,6 +499,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven summary=[], type="reasoning", status=None, + encrypted_content="AAA", ), output_index=output_index, sequence_number=0, @@ -510,6 +511,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven summary=[], type="reasoning", status=None, + encrypted_content="AAABBB", ), output_index=output_index, sequence_number=0, @@ -566,7 +568,7 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve async def test_function_call( hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, + mock_config_entry_with_reasoning_model: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 @@ -617,7 +619,7 @@ async def test_function_call( "id": "rs_A", "summary": [], "type": "reasoning", - "encrypted_content": None, + "encrypted_content": "AAABBB", } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic From c75b34a91114acf1aac887509ac41d225f8ca635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Thu, 10 Jul 2025 10:52:03 +0200 Subject: [PATCH 0073/1118] Fix for Renson set Breeze fan speed (#148537) --- homeassistant/components/renson/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 474ab640943..c82cad012c3 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -196,7 +196,7 @@ class RensonFan(RensonEntity, FanEntity): all_data = self.coordinator.data breeze_temp = self.api.get_field_value(all_data, BREEZE_TEMPERATURE_FIELD) await self.hass.async_add_executor_job( - self.api.set_breeze, cmd.name, breeze_temp, True + self.api.set_breeze, cmd, breeze_temp, True ) else: await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) From c37b0a8f1d10b045f91ebdc95e43c791621b0a30 Mon Sep 17 00:00:00 2001 From: Josh Barnard Date: Thu, 10 Jul 2025 02:21:44 -0700 Subject: [PATCH 0074/1118] Adding precision for voltage and wind speed sensors in Ecowitt (#148462) --- homeassistant/components/ecowitt/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 7d37aa40b86..ccaaeaae3de 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -106,6 +106,7 @@ ECOWITT_SENSORS_MAPPING: Final = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, ), EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( key="CO2_PPM", @@ -191,12 +192,14 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription( key="SPEED_MPH", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription( key="PRESSURE_HPA", From a00f61f7be7dc82d73addc9355b3de38249997ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 10 Jul 2025 12:09:24 +0200 Subject: [PATCH 0075/1118] Remove vg argument from miele auth flow (#148541) --- homeassistant/components/miele/config_flow.py | 8 -------- tests/components/miele/test_config_flow.py | 4 ---- 2 files changed, 12 deletions(-) diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py index d3c7dbba12b..191cd9a0454 100644 --- a/homeassistant/components/miele/config_flow.py +++ b/homeassistant/components/miele/config_flow.py @@ -26,14 +26,6 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - # "vg" is mandatory but the value doesn't seem to matter - return { - "vg": "sv-SE", - } - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py index bbe5844c1cd..5ce129b255d 100644 --- a/tests/components/miele/test_config_flow.py +++ b/tests/components/miele/test_config_flow.py @@ -46,7 +46,6 @@ async def test_full_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -118,7 +117,6 @@ async def test_flow_reauth_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -187,7 +185,6 @@ async def test_flow_reconfigure_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -247,7 +244,6 @@ async def test_zeroconf_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() From 8881919efde3b0fd18005666d08ef1b84915c99a Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 10 Jul 2025 18:10:15 +0800 Subject: [PATCH 0076/1118] Add YS8009 support to Yolink (#148538) --- homeassistant/components/yolink/manifest.json | 2 +- homeassistant/components/yolink/sensor.py | 19 ++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 779b830637b..89001f98c16 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.5.5"] + "requirements": ["yolink-api==0.5.7"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index bc32d0eea83..2845f8ee533 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,7 @@ from yolink.const import ( ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -42,6 +43,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfConductivity, UnitOfEnergy, UnitOfLength, UnitOfPower, @@ -103,6 +105,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, ] BATTERY_POWER_SENSOR = [ @@ -122,6 +125,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, ] MCU_DEV_TEMPERATURE_SENSOR = [ @@ -182,7 +186,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: ( - device.device_type in [ATTR_DEVICE_TH_SENSOR] + device.device_type in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR] and device.device_model_name not in NONE_HUMIDITY_SENSOR_MODELS ), ), @@ -191,7 +195,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR], + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR], ), # mcu temperature YoLinkSensorEntityDescription( @@ -206,7 +211,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="loraInfo", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value=lambda value: value["signal"] if value is not None else None, + value=lambda value: value.get("signal") if value is not None else None, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -302,6 +307,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( exists_fn=lambda device: device.device_model_name in POWER_SUPPORT_MODELS, value=lambda value: value / 100 if value is not None else None, ), + YoLinkSensorEntityDescription( + key="conductivity", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], + should_update_entity=lambda value: value is not None, + ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 3fae8953386..b4a53c7dba7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3163,7 +3163,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.7 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 827b088fdab..3173b1443b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2610,7 +2610,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.7 # homeassistant.components.youless youless-api==2.2.0 From 2829cc1248f2c5d2a02baaa41b143337a131aef3 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 10 Jul 2025 04:24:54 -0600 Subject: [PATCH 0077/1118] Add visits today sensor for pets (#147459) --- .../components/litterrobot/coordinator.py | 3 +++ homeassistant/components/litterrobot/icons.json | 3 +++ homeassistant/components/litterrobot/sensor.py | 16 +++++++++++++++- .../components/litterrobot/strings.json | 4 ++++ tests/components/litterrobot/common.py | 9 +++++++++ tests/components/litterrobot/conftest.py | 16 +++++++++++++++- tests/components/litterrobot/test_sensor.py | 10 ++++++++++ 7 files changed, 59 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index c99d4794ff6..581257ab2db 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -48,6 +48,9 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): """Update all device states from the Litter-Robot API.""" await self.account.refresh_robots() await self.account.load_pets() + for pet in self.account.pets: + # Need to fetch weight history for `get_visits_since` + await pet.fetch_weight_history() async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 2e0cafe43d9..86a95b59b18 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -49,6 +49,9 @@ }, "total_cycles": { "default": "mdi:counter" + }, + "visits_today": { + "default": "mdi:counter" } }, "switch": { diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index b7ddf3c3249..aa7c3a451be 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -39,6 +40,7 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None + last_reset_fn: Callable[[], datetime | None] = lambda: None value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] @@ -179,7 +181,14 @@ PET_SENSORS: list[RobotSensorEntityDescription] = [ native_unit_of_measurement=UnitOfMass.POUNDS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda pet: pet.weight, - ) + ), + RobotSensorEntityDescription[Pet]( + key="visits_today", + translation_key="visits_today", + state_class=SensorStateClass.TOTAL, + last_reset_fn=dt_util.start_of_local_day, + value_fn=lambda pet: pet.get_visits_since(dt_util.start_of_local_day()), + ), ] @@ -225,3 +234,8 @@ class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): if (icon := self.entity_description.icon_fn(self.state)) is not None: return icon return super().icon + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + return self.entity_description.last_reset_fn() or super().last_reset diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 160f5edb6a0..35aff0f9105 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -122,6 +122,10 @@ "name": "Total cycles", "unit_of_measurement": "cycles" }, + "visits_today": { + "name": "Visits today", + "unit_of_measurement": "visits" + }, "waste_drawer": { "name": "Waste drawer" } diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index d96ce06ca59..19c0c3600ea 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -159,6 +159,15 @@ PET_DATA = { "gender": "FEMALE", "lastWeightReading": 9.1, "breeds": ["sphynx"], + "weightHistory": [ + {"weight": 6.48, "timestamp": "2025-06-13T16:12:36"}, + {"weight": 6.6, "timestamp": "2025-06-14T03:52:00"}, + {"weight": 6.59, "timestamp": "2025-06-14T17:20:32"}, + {"weight": 6.5, "timestamp": "2025-06-14T19:22:48"}, + {"weight": 6.35, "timestamp": "2025-06-15T03:12:15"}, + {"weight": 6.45, "timestamp": "2025-06-15T15:27:21"}, + {"weight": 6.25, "timestamp": "2025-06-15T15:29:26"}, + ], } VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index a6058c75bca..aa67db23d89 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -52,6 +52,20 @@ def create_mock_robot( return robot +def create_mock_pet( + pet_data: dict | None, + account: Account, + side_effect: Any | None = None, +) -> Pet: + """Create a mock Pet.""" + if not pet_data: + pet_data = {} + + pet = Pet(data={**PET_DATA, **pet_data}, session=account.session) + pet.fetch_weight_history = AsyncMock(side_effect=side_effect) + return pet + + def create_mock_account( robot_data: dict | None = None, side_effect: Any | None = None, @@ -69,7 +83,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) - account.pets = [Pet(PET_DATA, account.session)] if pet else [] + account.pets = [create_mock_pet(PET_DATA, account, side_effect)] if pet else [] return account diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 76c567f5417..d1101a4231d 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -124,6 +124,16 @@ async def test_pet_weight_sensor( assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS +@pytest.mark.freeze_time("2025-06-15 12:00:00+00:00") +async def test_pet_visits_today_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet visits today sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_visits_today") + assert sensor.state == "2" + + async def test_litterhopper_sensor( hass: HomeAssistant, mock_account_with_litterhopper: MagicMock ) -> None: From 7e405d4ddb88a6d3e54791f806b31ac5842c7fef Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 10 Jul 2025 04:21:19 -0700 Subject: [PATCH 0078/1118] 100% test coverage in Google Assistant SDK (#148536) --- .../google_assistant_sdk/test_init.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 9bb08c802c2..caddf9ba797 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -116,6 +116,25 @@ async def test_expired_token_refresh_failure( assert entries[0].state is expected_state +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_setup_client_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup handling aiohttp.ClientError.""" + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + exc=aiohttp.ClientError, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("configured_language_code", "expected_language_code"), [("", "en-US"), ("en-US", "en-US"), ("es-ES", "es-ES")], From 12f913e7370077fa1c16c36dc20b175dcb3ed62f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Jul 2025 13:38:42 +0200 Subject: [PATCH 0079/1118] Improve names and descriptions of `rainmachine.push_weather_data` (#148534) --- .../components/rainmachine/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index aad61458e88..49731df5b6f 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -196,12 +196,12 @@ "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { - "name": "Min temp", - "description": "Minimum temperature (°C)." + "name": "Min temperature", + "description": "Minimum temperature in current period (°C)." }, "maxtemp": { - "name": "Max temp", - "description": "Maximum temperature (°C)." + "name": "Max temperature", + "description": "Maximum temperature in current period (°C)." }, "temperature": { "name": "Temperature", @@ -209,11 +209,11 @@ }, "wind": { "name": "Wind speed", - "description": "Wind speed (m/s)." + "description": "Current wind speed (m/s)." }, "solarrad": { "name": "Solar radiation", - "description": "Solar radiation (MJ/m²/h)." + "description": "Current solar radiation (MJ/m²/h)." }, "et": { "name": "Evapotranspiration", @@ -229,11 +229,11 @@ }, "minrh": { "name": "Min relative humidity", - "description": "Min relative humidity (%RH)." + "description": "Minimum relative humidity in current period (%RH)." }, "maxrh": { "name": "Max relative humidity", - "description": "Max relative humidity (%RH)." + "description": "Maximum relative humidity in current period (%RH)." }, "condition": { "name": "Weather condition code", @@ -241,11 +241,11 @@ }, "pressure": { "name": "Barametric pressure", - "description": "Barametric pressure (kPa)." + "description": "Current barametric pressure (kPa)." }, "dewpoint": { "name": "Dew point", - "description": "Dew point (°C)." + "description": "Current dew point (°C)." } } }, From eb20292683ecb9cfc06439971286b46e09e2eb60 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:54:05 +0200 Subject: [PATCH 0080/1118] Move tuya models to separate module (#148550) --- .../components/tuya/alarm_control_panel.py | 3 +- homeassistant/components/tuya/climate.py | 3 +- homeassistant/components/tuya/cover.py | 3 +- homeassistant/components/tuya/entity.py | 120 +---------------- homeassistant/components/tuya/fan.py | 3 +- homeassistant/components/tuya/humidifier.py | 3 +- homeassistant/components/tuya/light.py | 3 +- homeassistant/components/tuya/models.py | 124 ++++++++++++++++++ homeassistant/components/tuya/number.py | 3 +- homeassistant/components/tuya/sensor.py | 3 +- homeassistant/components/tuya/vacuum.py | 3 +- 11 files changed, 144 insertions(+), 127 deletions(-) create mode 100644 homeassistant/components/tuya/models.py diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 4972fe88339..61985fb7622 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -20,7 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 991c3589e12..734f6ba7f7a 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -25,7 +25,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 015daae4212..a385a35d903 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index cc258560067..4158650b062 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -2,11 +2,7 @@ from __future__ import annotations -import base64 -from dataclasses import dataclass -import json -import struct -from typing import Any, Literal, Self, overload +from typing import Any, Literal, overload from tuya_sharing import CustomerDevice, Manager @@ -15,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType -from .util import remap_value +from .models import EnumTypeData, IntegerTypeData _DPTYPE_MAPPING: dict[str, DPType] = { "Bitmap": DPType.RAW, @@ -29,118 +25,6 @@ _DPTYPE_MAPPING: dict[str, DPType] = { } -@dataclass -class IntegerTypeData: - """Integer Type Data.""" - - dpcode: DPCode - min: int - max: int - scale: float - step: float - unit: str | None = None - type: str | None = None - - @property - def max_scaled(self) -> float: - """Return the max scaled.""" - return self.scale_value(self.max) - - @property - def min_scaled(self) -> float: - """Return the min scaled.""" - return self.scale_value(self.min) - - @property - def step_scaled(self) -> float: - """Return the step scaled.""" - return self.step / (10**self.scale) - - def scale_value(self, value: float) -> float: - """Scale a value.""" - return value / (10**self.scale) - - def scale_value_back(self, value: float) -> int: - """Return raw value for scaled.""" - return int(value * (10**self.scale)) - - def remap_value_to( - self, - value: float, - to_min: float = 0, - to_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from this range to a new range.""" - return remap_value(value, self.min, self.max, to_min, to_max, reverse) - - def remap_value_from( - self, - value: float, - from_min: float = 0, - from_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from its current range to this range.""" - return remap_value(value, from_min, from_max, self.min, self.max, reverse) - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: - """Load JSON string and return a IntegerTypeData object.""" - if not (parsed := json.loads(data)): - return None - - return cls( - dpcode, - min=int(parsed["min"]), - max=int(parsed["max"]), - scale=float(parsed["scale"]), - step=max(float(parsed["step"]), 1), - unit=parsed.get("unit"), - type=parsed.get("type"), - ) - - -@dataclass -class EnumTypeData: - """Enum Type Data.""" - - dpcode: DPCode - range: list[str] - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: - """Load JSON string and return a EnumTypeData object.""" - if not (parsed := json.loads(data)): - return None - return cls(dpcode, **parsed) - - -@dataclass -class ElectricityTypeData: - """Electricity Type Data.""" - - electriccurrent: str | None = None - power: str | None = None - voltage: str | None = None - - @classmethod - def from_json(cls, data: str) -> Self: - """Load JSON string and return a ElectricityTypeData object.""" - return cls(**json.loads(data.lower())) - - @classmethod - def from_raw(cls, data: str) -> Self: - """Decode base64 string and return a ElectricityTypeData object.""" - raw = base64.b64decode(data) - voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 - electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 - power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 - return cls( - electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) - ) - - class TuyaEntity(Entity): """Tuya base device.""" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f2d856b6d86..f96ea2c0a65 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -22,7 +22,8 @@ from homeassistant.util.percentage import ( from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData, IntegerTypeData TUYA_SUPPORT_TYPE = { "cs", # Dehumidifier diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index f8fd9237ffc..6539d98e9d8 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -19,7 +19,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 37c79b952d4..3f8fc7d0fb9 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -25,7 +25,8 @@ from homeassistant.util import color as color_util from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData from .util import remap_value diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py new file mode 100644 index 00000000000..b4afca83a85 --- /dev/null +++ b/homeassistant/components/tuya/models.py @@ -0,0 +1,124 @@ +"""Tuya Home Assistant Base Device Model.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +import json +import struct +from typing import Self + +from .const import DPCode +from .util import remap_value + + +@dataclass +class IntegerTypeData: + """Integer Type Data.""" + + dpcode: DPCode + min: int + max: int + scale: float + step: float + unit: str | None = None + type: str | None = None + + @property + def max_scaled(self) -> float: + """Return the max scaled.""" + return self.scale_value(self.max) + + @property + def min_scaled(self) -> float: + """Return the min scaled.""" + return self.scale_value(self.min) + + @property + def step_scaled(self) -> float: + """Return the step scaled.""" + return self.step / (10**self.scale) + + def scale_value(self, value: float) -> float: + """Scale a value.""" + return value / (10**self.scale) + + def scale_value_back(self, value: float) -> int: + """Return raw value for scaled.""" + return int(value * (10**self.scale)) + + def remap_value_to( + self, + value: float, + to_min: float = 0, + to_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from this range to a new range.""" + return remap_value(value, self.min, self.max, to_min, to_max, reverse) + + def remap_value_from( + self, + value: float, + from_min: float = 0, + from_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from its current range to this range.""" + return remap_value(value, from_min, from_max, self.min, self.max, reverse) + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: + """Load JSON string and return a IntegerTypeData object.""" + if not (parsed := json.loads(data)): + return None + + return cls( + dpcode, + min=int(parsed["min"]), + max=int(parsed["max"]), + scale=float(parsed["scale"]), + step=max(float(parsed["step"]), 1), + unit=parsed.get("unit"), + type=parsed.get("type"), + ) + + +@dataclass +class EnumTypeData: + """Enum Type Data.""" + + dpcode: DPCode + range: list[str] + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: + """Load JSON string and return a EnumTypeData object.""" + if not (parsed := json.loads(data)): + return None + return cls(dpcode, **parsed) + + +@dataclass +class ElectricityTypeData: + """Electricity Type Data.""" + + electriccurrent: str | None = None + power: str | None = None + voltage: str | None = None + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ElectricityTypeData object.""" + return cls(**json.loads(data.lower())) + + @classmethod + def from_raw(cls, data: str) -> Self: + """Decode base64 string and return a ElectricityTypeData object.""" + raw = base64.b64decode(data) + voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 + electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 + power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 + return cls( + electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) + ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index ddee46b8799..b5b8437ea8b 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -16,7 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 5151f39eb26..b45b8214bff 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -35,7 +35,8 @@ from .const import ( DPType, UnitOfMeasurement, ) -from .entity import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import ElectricityTypeData, EnumTypeData, IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index f722fd918ca..d61a624f027 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -17,7 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData, IntegerTypeData TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { From d23321cf54a787b0d809f8c11396bf4a9c700e38 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:55:03 +0200 Subject: [PATCH 0081/1118] Add tuya snapshot tests for dlq category (#148549) --- tests/components/tuya/__init__.py | 9 + tests/components/tuya/conftest.py | 2 +- .../fixtures/dlq_earu_electric_eawcpt.json | 247 +++++++ .../tuya/fixtures/dlq_metering_3pn_wifi.json | 137 ++++ .../tuya/snapshots/test_sensor.ambr | 672 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 96 +++ 6 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json create mode 100644 tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index b308df7e2f9..011b2fc7d31 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,15 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "dlq_earu_electric_eawcpt": [ + # https://github.com/home-assistant/core/issues/102769 + Platform.SENSOR, + Platform.SWITCH, + ], + "dlq_metering_3pn_wifi": [ + # https://github.com/home-assistant/core/issues/143499 + Platform.SENSOR, + ], "kg_smart_valve": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 9aa8e8ea147..3d89e1d6f92 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -143,7 +143,7 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev hass, f"{mock_device_code}.json", DOMAIN ) device = MagicMock(spec=CustomerDevice) - device.id = details["id"] + device.id = details.get("id", "mocked_device_id") device.name = details["name"] device.category = details["category"] device.product_id = details["product_id"] diff --git a/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json b/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json new file mode 100644 index 00000000000..32535964a7e --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json @@ -0,0 +1,247 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "48", + "app_type": "tuyaSmart", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "model": "", + "category": "dlq", + "product_id": "0tnvg2xaisqdadcf", + "product_name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-25T21:50:37+00:00", + "create_time": "2023-11-25T21:49:06+00:00", + "update_time": "2023-11-28T16:32:28+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 99999, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "ov_cr", + "ov_vol", + "ov_pwr", + "ls_cr", + "ls_vol", + "ls_pow", + "short_circuit_alarm", + "overload_alarm", + "leakagecurr_alarm", + "self_test_alarm", + "high_temp", + "unbalance_alarm", + "miss_phase_alarm" + ] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "add_ele": 100, + "cur_current": 2198, + "cur_power": 4953, + "cur_voltage": 2314, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "fault": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "temp_value": 0, + "alarm_set_1": "", + "alarm_set_2": "AQAAAAMAAAAEAAAA" + } +} diff --git a/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json b/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json new file mode 100644 index 00000000000..8e9a06cc9a9 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json @@ -0,0 +1,137 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1733006572651YokbqV", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "id": "bf5e5bde2c52cb5994cd27", + "name": "Metering_3PN_WiFi_stable", + "category": "dlq", + "product_id": "kxdr6su0c55p7bbo", + "product_name": "Metering_3PN_WiFi", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2024-12-08T17:37:45+00:00", + "create_time": "2024-12-08T17:37:45+00:00", + "update_time": "2024-12-08T17:37:45+00:00", + "function": { + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "forward_energy_total": 2435416, + "phase_a": "CKMAAn0AAGw=", + "phase_b": "CIsAK8MACWo=", + "phase_c": "CJwAA5EAAFw=", + "fault": 0, + "energy_reset": "", + "alarm_set_1": "BwEADQ==", + "alarm_set_2": "AQEAPAMBAP0EAQC0BQEAAAcBAAAIAQAeCQAAAA==", + "breaker_number": "SPM02_6588", + "supply_frequency": 5000, + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index ac34dc615b7..946d0bc004d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -528,6 +528,678 @@ 'state': '121.7', }) # --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.mocked_device_idcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '一路带计量磁保持通断器 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.198', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.mocked_device_idcur_power', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '一路带计量磁保持通断器 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '495.3', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.mocked_device_idcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '一路带计量磁保持通断器 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.4', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.637', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.108', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '221.1', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.203', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218.7', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.913', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.092', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.4', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 0f042cbce52..77943ccdd29 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,102 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-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': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.mocked_device_idchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Child lock', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.mocked_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Switch', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 058e1ede10c288d09248f6ab72afbc1d7faa7f66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:55:22 +0200 Subject: [PATCH 0082/1118] Add tuya snapshot tests for wsdcg and zndb category (#148554) --- tests/components/tuya/__init__.py | 8 + .../fixtures/wsdcg_temperature_humidity.json | 158 +++++++++ .../tuya/fixtures/zndb_smart_meter.json | 79 +++++ .../tuya/snapshots/test_sensor.ambr | 330 ++++++++++++++++++ 4 files changed, 575 insertions(+) create mode 100644 tests/components/tuya/fixtures/wsdcg_temperature_humidity.json create mode 100644 tests/components/tuya/fixtures/zndb_smart_meter.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 011b2fc7d31..5e182f936de 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -83,6 +83,14 @@ DEVICE_MOCKS = { Platform.CLIMATE, Platform.SWITCH, ], + "wsdcg_temperature_humidity": [ + # https://github.com/home-assistant/core/issues/102769 + Platform.SENSOR, + ], + "zndb_smart_meter": [ + # https://github.com/home-assistant/core/issues/138372 + Platform.SENSOR, + ], } diff --git a/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json b/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json new file mode 100644 index 00000000000..06d07a4c506 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json @@ -0,0 +1,158 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "17150293164666xhFUk", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + + "id": "bf316b8707b061f044th18", + "name": "NP DownStairs North", + "category": "wsdcg", + "product_id": "g2y6z3p3ja2qhyav", + "product_name": "\u6e29\u6e7f\u5ea6\u4f20\u611f\u5668wifi", + "online": true, + "sub": false, + "time_zone": "+10:30", + "active_time": "2023-12-22T03:38:57+00:00", + "create_time": "2023-12-22T03:38:57+00:00", + "update_time": "2023-12-22T03:38:57+00:00", + "function": { + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + } + }, + "status": { + "va_temperature": 185, + "va_humidity": 47, + "battery_percentage": 0, + "maxtemp_set": 600, + "minitemp_set": -100, + "maxhum_set": 100, + "minihum_set": 0, + "temp_alarm": "cancel", + "hum_alarm": "cancel" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_smart_meter.json b/tests/components/tuya/fixtures/zndb_smart_meter.json new file mode 100644 index 00000000000..139cf814347 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_smart_meter.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1739198173271wpFacM", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfe33b4c74661f1f1bgacy", + "name": "Meter", + "category": "zndb", + "product_id": "ze8faryrxr0glqnn", + "product_name": "PJ2101A 1P WiFi Smart Meter ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-24T11:22:33+00:00", + "create_time": "2024-08-24T11:22:33+00:00", + "update_time": "2024-08-24T11:22:33+00:00", + "function": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + } + }, + "status_range": { + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + }, + "phase_a": { + "type": "raw", + "value": {} + }, + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "energy_month": "GAkYCQAAANQ=", + "energy_daily": "", + "phase_a": "CSIAFfQABKE=" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 946d0bc004d..5e52c0e063c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1305,3 +1305,333 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf316b8707b061f044th18battery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'NP DownStairs North Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf316b8707b061f044th18va_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'NP DownStairs North Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf316b8707b061f044th18va_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'NP DownStairs North Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_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.meter_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Meter Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.62', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_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.meter_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Meter Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.185', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_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.meter_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Meter Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.8', + }) +# --- From 4f27058a687707e578f17a9a1491a68409c7c076 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:15:07 +0200 Subject: [PATCH 0083/1118] Add fault binary sensors to tuya dehumidifer (#148485) --- .../components/tuya/binary_sensor.py | 75 ++++++++- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/entity.py | 3 +- homeassistant/components/tuya/strings.json | 9 ++ tests/components/tuya/__init__.py | 1 + .../tuya/snapshots/test_binary_sensor.ambr | 147 ++++++++++++++++++ tests/components/tuya/test_binary_sensor.py | 35 +++++ 7 files changed, 264 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a613661149f..4fef11a7335 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -15,9 +15,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity @@ -31,6 +32,9 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): # Value or values to consider binary sensor to be "on" on_value: bool | float | int | str | set[bool | float | int | str] = True + # For DPType.BITMAP, the bitmap_key is used to extract the bit mask + bitmap_key: str | None = None + # Commonly used sensors TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( @@ -71,6 +75,34 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + TuyaBinarySensorEntityDescription( + key="tankfull", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="tankfull", + translation_key="tankfull", + ), + TuyaBinarySensorEntityDescription( + key="defrost", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="defrost", + translation_key="defrost", + ), + TuyaBinarySensorEntityDescription( + key="wet", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="wet", + translation_key="wet", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -343,6 +375,22 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { } +def _get_bitmap_bit_mask( + device: CustomerDevice, dpcode: str, bitmap_key: str | None +) -> int | None: + """Get the bit mask for a given bitmap description.""" + if ( + bitmap_key is None + or (status_range := device.status_range.get(dpcode)) is None + or status_range.type != DPType.BITMAP + or not isinstance(bitmap_values := json_loads(status_range.values), dict) + or not isinstance(bitmap_labels := bitmap_values.get("label"), list) + or bitmap_key not in bitmap_labels + ): + return None + return bitmap_labels.index(bitmap_key) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -361,12 +409,23 @@ async def async_setup_entry( for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: - entities.append( - TuyaBinarySensorEntity( - device, hass_data.manager, description - ) + mask = _get_bitmap_bit_mask( + device, dpcode, description.bitmap_key ) + if ( + description.bitmap_key is None # Regular binary sensor + or mask is not None # Bitmap sensor with valid mask + ): + entities.append( + TuyaBinarySensorEntity( + device, + hass_data.manager, + description, + mask, + ) + ) + async_add_entities(entities) async_discover_device([*hass_data.manager.device_map]) @@ -386,11 +445,13 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): device: CustomerDevice, device_manager: Manager, description: TuyaBinarySensorEntityDescription, + bit_mask: int | None = None, ) -> None: """Init Tuya binary sensor.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._bit_mask = bit_mask @property def is_on(self) -> bool: @@ -399,6 +460,10 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): if dpcode not in self.device.status: return False + if self._bit_mask is not None: + # For bitmap sensors, check the specific bit mask + return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0 + if isinstance(self.entity_description.on_value, set): return self.device.status[dpcode] in self.entity_description.on_value diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 922aaab193b..abf5223175c 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -82,6 +82,7 @@ class WorkMode(StrEnum): class DPType(StrEnum): """Data point types.""" + BITMAP = "Bitmap" BOOLEAN = "Boolean" ENUM = "Enum" INTEGER = "Integer" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 4158650b062..fbddfb0ab83 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -14,8 +14,7 @@ from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType from .models import EnumTypeData, IntegerTypeData _DPTYPE_MAPPING: dict[str, DPType] = { - "Bitmap": DPType.RAW, - "bitmap": DPType.RAW, + "bitmap": DPType.BITMAP, "bool": DPType.BOOLEAN, "enum": DPType.ENUM, "json": DPType.JSON, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a96f805f248..5964be5ce34 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -56,6 +56,15 @@ }, "tilt": { "name": "Tilt" + }, + "tankfull": { + "name": "Tank full" + }, + "defrost": { + "name": "Defrost" + }, + "wet": { + "name": "Wet" } }, "button": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5e182f936de..bf8af8835cf 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -19,6 +19,7 @@ DEVICE_MOCKS = { Platform.LIGHT, ], "cs_arete_two_12l_dehumidifier_air_purifier": [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.SELECT, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index b269664a2d4..efd995b3280 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,4 +1,151 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-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.dehumidifier_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqdefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-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.dehumidifier_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-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.dehumidifier_wet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wet', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqwet', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Wet', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index c77be47fb2d..f59e325b6cc 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -56,3 +56,38 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +@pytest.mark.parametrize( + ("fault_value", "tankfull", "defrost", "wet"), + [ + (0, "off", "off", "off"), + (0x1, "on", "off", "off"), + (0x2, "off", "on", "off"), + (0x80, "off", "off", "on"), + (0x83, "on", "on", "on"), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_bitmap( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + fault_value: int, + tankfull: str, + defrost: str, + wet: str, +) -> None: + """Test BITMAP fault sensor on cs_arete_two_12l_dehumidifier_air_purifier.""" + mock_device.status["fault"] = fault_value + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == tankfull + assert hass.states.get("binary_sensor.dehumidifier_defrost").state == defrost + assert hass.states.get("binary_sensor.dehumidifier_wet").state == wet From d15baf9f9fdadd8b5d45da4f1d4e16cffed85361 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 10 Jul 2025 08:30:54 -0700 Subject: [PATCH 0084/1118] Drop homeassistant agent and assist_pipeline migration code (#147968) Co-authored-by: Franck Nijhof Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/__init__.py | 4 - .../components/assist_pipeline/const.py | 1 - .../components/assist_pipeline/pipeline.py | 47 +------ .../components/conversation/__init__.py | 11 -- .../components/conversation/agent_manager.py | 9 +- .../components/conversation/const.py | 1 - .../conversation.py | 5 +- .../components/ollama/conversation.py | 5 +- .../openai_conversation/conversation.py | 5 +- script/hassfest/dependencies.py | 2 - tests/components/assist_pipeline/test_init.py | 8 +- .../assist_pipeline/test_pipeline.py | 52 +------- .../conversation/snapshots/test_http.ambr | 31 ----- .../conversation/snapshots/test_init.ambr | 124 ------------------ tests/components/conversation/test_http.py | 9 +- tests/components/conversation/test_init.py | 13 +- 16 files changed, 26 insertions(+), 301 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 59bd987d90e..8f4c6efd355 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -38,8 +38,6 @@ from .pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, - async_run_migrations, async_setup_pipeline_store, async_update_pipeline, ) @@ -61,7 +59,6 @@ __all__ = ( "WakeWordSettings", "async_create_default_pipeline", "async_get_pipelines", - "async_migrate_engine", "async_pipeline_from_audio_stream", "async_setup", "async_update_pipeline", @@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_LAST_WAKE_UP] = {} await async_setup_pipeline_store(hass) - await async_run_migrations(hass) async_register_websocket_api(hass) return True diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 300cb5aad2a..52583cf21a4 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -3,7 +3,6 @@ DOMAIN = "assist_pipeline" DATA_CONFIG = f"{DOMAIN}.config" -DATA_MIGRATIONS = f"{DOMAIN}_migrations" DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a1b6ea53445..0cd593e9666 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -13,7 +13,7 @@ from pathlib import Path from queue import Empty, Queue from threading import Thread import time -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast import wave import hass_nabucasa @@ -49,7 +49,6 @@ from .const import ( CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, - DATA_MIGRATIONS, DOMAIN, MS_PER_CHUNK, SAMPLE_CHANNELS, @@ -2059,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: return PipelineData(pipeline_store) -@callback -def async_migrate_engine( - hass: HomeAssistant, - engine_type: Literal["conversation", "stt", "tts", "wake_word"], - old_value: str, - new_value: str, -) -> None: - """Register a migration of an engine used in pipelines.""" - hass.data.setdefault(DATA_MIGRATIONS, {})[engine_type] = (old_value, new_value) - - # Run migrations when config is already loaded - if DATA_CONFIG in hass.data: - hass.async_create_background_task( - async_run_migrations(hass), "assist_pipeline_migration", eager_start=True - ) - - -async def async_run_migrations(hass: HomeAssistant) -> None: - """Run pipeline migrations.""" - if not (migrations := hass.data.get(DATA_MIGRATIONS)): - return - - engine_attr = { - "conversation": "conversation_engine", - "stt": "stt_engine", - "tts": "tts_engine", - "wake_word": "wake_word_entity", - } - - updates = [] - - for pipeline in async_get_pipelines(hass): - attr_updates = {} - for engine_type, (old_value, new_value) in migrations.items(): - if getattr(pipeline, engine_attr[engine_type]) == old_value: - attr_updates[engine_attr[engine_type]] = new_value - - if attr_updates: - updates.append((pipeline, attr_updates)) - - for pipeline, attr_updates in updates: - await async_update_pipeline(hass, pipeline, **attr_updates) - - @dataclass class PipelineConversationData: """Hold data for the duration of a conversation.""" diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index cf62704b34d..66a5735e6b6 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -51,7 +51,6 @@ from .const import ( DATA_DEFAULT_ENTITY, DOMAIN, HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, @@ -65,7 +64,6 @@ from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", - "OLD_HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", "ChatLog", @@ -270,15 +268,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) ) - # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 - async_migrate_engine, - ) - - async_migrate_engine( - hass, "conversation", OLD_HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT - ) - async def handle_process(service: ServiceCall) -> ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 38c0ca8db6b..6203525ac01 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -12,12 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton -from .const import ( - DATA_COMPONENT, - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, -) +from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -54,7 +49,7 @@ def async_get_agent( hass: HomeAssistant, agent_id: str | None = None ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" - if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT): + if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 619a41fd002..266a9f15b83 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" -OLD_HOME_ASSISTANT_AGENT = "homeassistant" ATTR_TEXT = "text" ATTR_LANGUAGE = "language" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0b24e8bbc38..3525fba3af5 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ class GoogleGenerativeAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index f151f8524a0..e0b64702cb4 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -52,9 +52,6 @@ class OllamaConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1ec17163f69..25e89577ef3 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ class OpenAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index ee932280201..447b3ec79b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -140,8 +140,6 @@ IGNORE_VIOLATIONS = { ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Temporary needed for migration until 2024.10 - ("conversation", "assist_pipeline"), } diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 0294f9953db..a6a449bddd4 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -12,7 +12,7 @@ import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, conversation, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, @@ -116,7 +116,7 @@ async def test_pipeline_from_audio_stream_legacy( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -184,7 +184,7 @@ async def test_pipeline_from_audio_stream_entity( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -252,7 +252,7 @@ async def test_pipeline_from_audio_stream_no_stt( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 1302925dab9..3a4895440dc 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -29,7 +29,6 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, async_update_pipeline, ) from homeassistant.const import MATCH_ALL @@ -162,12 +161,6 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" - async_migrate_engine( - hass, - "conversation", - conversation.OLD_HOME_ASSISTANT_AGENT, - conversation.HOME_ASSISTANT_AGENT, - ) id_1 = "01GX8ZWBAQYWNB1XV3EXEZ75DY" hass_storage[STORAGE_KEY] = { "version": STORAGE_VERSION, @@ -176,7 +169,7 @@ async def test_loading_pipelines_from_storage( "data": { "items": [ { - "conversation_engine": conversation.OLD_HOME_ASSISTANT_AGENT, + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "language_1", "id": id_1, "language": "language_1", @@ -668,43 +661,6 @@ async def test_update_pipeline( } -@pytest.mark.usefixtures("init_supporting_components") -async def test_migrate_after_load(hass: HomeAssistant) -> None: - """Test migrating an engine after done loading.""" - assert await async_setup_component(hass, "assist_pipeline", {}) - - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 - - assert ( - await async_create_default_pipeline( - hass, - stt_engine_id="bla", - tts_engine_id="bla", - pipeline_name="Bla pipeline", - ) - is None - ) - pipeline = await async_create_default_pipeline( - hass, - stt_engine_id="test", - tts_engine_id="test", - pipeline_name="Test pipeline", - ) - assert pipeline is not None - - async_migrate_engine(hass, "stt", "test", "stt.test") - async_migrate_engine(hass, "tts", "test", "tts.test") - - await hass.async_block_till_done(wait_background_tasks=True) - - pipeline_updated = async_get_pipeline(hass, pipeline.id) - - assert pipeline_updated.stt_engine == "stt.test" - assert pipeline_updated.tts_engine == "tts.test" - - def test_fallback_intent_filter() -> None: """Test that we filter the right things.""" assert ( @@ -1364,7 +1320,7 @@ async def test_stt_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1440,7 +1396,7 @@ async def test_tts_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1516,7 +1472,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 5179409deb0..391fb609d65 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -327,37 +327,6 @@ }), }) # --- -# name: test_http_processing_intent[homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_ws_api[payload0] dict({ 'continue_conversation': False, diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index a853faa7a3d..779bb256180 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -108,37 +108,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -201,37 +170,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ 'continue_conversation': False, @@ -294,37 +232,6 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -387,34 +294,3 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 77fa97ad845..29cd567e904 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -8,7 +8,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -22,8 +25,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -187,7 +188,7 @@ async def test_http_api_wrong_data( }, { "text": "Test Text", - "agent_id": "homeassistant", + "agent_id": HOME_ASSISTANT_AGENT, }, ], ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index c3de5f1127c..e757c56042b 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -14,7 +14,10 @@ from homeassistant.components.conversation import ( async_handle_sentence_triggers, default_agent, ) -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -28,8 +31,6 @@ from tests.typing import ClientSessionGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -205,8 +206,8 @@ async def test_get_agent_info( """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default - assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info - assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == agent_info + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == snapshot assert ( conversation.async_get_agent_info(hass, mock_conversation_agent.agent_id) == snapshot @@ -223,7 +224,7 @@ async def test_get_agent_info( default_agent = conversation.async_get_agent(hass) default_agent._attr_supports_streaming = True assert ( - conversation.async_get_agent_info(hass, "homeassistant").supports_streaming + conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT).supports_streaming is True ) From f0a636949af7484f55e83463cbef2060ddfa9285 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:29:48 -0700 Subject: [PATCH 0085/1118] Support all Energy units in Energy integration (#148566) --- homeassistant/components/energy/sensor.py | 9 ++---- homeassistant/components/energy/validate.py | 19 +++--------- tests/components/energy/test_sensor.py | 6 +++- tests/components/energy/test_validate.py | 34 +++++++++------------ 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3dc857d75d9..1105e6f6b86 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -41,13 +41,8 @@ SUPPORTED_STATE_CLASSES = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, } -VALID_ENERGY_UNITS: set[str] = { - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, -} +VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy) + VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0f46678994f..3590ee9e848 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -21,14 +21,9 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ) + sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) } + ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units ) @@ -39,13 +34,9 @@ GAS_USAGE_DEVICE_CLASSES = ( sensor.SensorDeviceClass.GAS, ) GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ), + sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[ + sensor.SensorDeviceClass.ENERGY + ], sensor.SensorDeviceClass.GAS: ( UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a9a249a8498..b7ccbadbe1c 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import _WH_TO_CAL, _WH_TO_J from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -748,10 +749,12 @@ async def test_cost_sensor_price_entity_total_no_reset( @pytest.mark.parametrize( ("energy_unit", "factor"), [ + (UnitOfEnergy.MILLIWATT_HOUR, 1e6), (UnitOfEnergy.WATT_HOUR, 1000), (UnitOfEnergy.KILO_WATT_HOUR, 1), (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), - (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), + (UnitOfEnergy.GIGA_JOULE, _WH_TO_J / 1e6), + (UnitOfEnergy.CALORIE, _WH_TO_CAL * 1e3), ], ) async def test_cost_sensor_handle_energy_units( @@ -815,6 +818,7 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( ("price_unit", "factor"), [ + (f"EUR/{UnitOfEnergy.MILLIWATT_HOUR}", 1e-6), (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 6389ac0b372..9e7a2151b04 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -12,6 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component +ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy)) + +ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy)) + @pytest.fixture def mock_is_entity_recorded(): @@ -69,6 +73,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("state_class", "energy_unit", "extra"), [ + ("total_increasing", UnitOfEnergy.MILLIWATT_HOUR, {}), ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), @@ -76,6 +81,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), + ("total_increasing", UnitOfEnergy.CALORIE, {}), ], ) async def test_validation( @@ -235,9 +241,7 @@ async def test_validation_device_consumption_entity_unexpected_unit( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.unexpected_unit", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -325,9 +329,7 @@ async def test_validation_solar( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.solar_production", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -378,9 +380,7 @@ async def test_validation_battery( ("sensor.battery_import", "beers"), ("sensor.battery_export", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, ] ], @@ -449,9 +449,7 @@ async def test_validation_grid( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -538,9 +536,7 @@ async def test_validation_grid_external_cost_compensation( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -710,9 +706,7 @@ async def test_validation_grid_auto_cost_entity_errors( { "type": "entity_unexpected_unit_energy_price", "affected_entities": {("sensor.grid_price_1", "$/Ws")}, - "translation_placeholders": { - "price_units": "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh" - }, + "translation_placeholders": {"price_units": ENERGY_PRICE_UNITS_STRING}, }, ), ], @@ -855,7 +849,7 @@ async def test_validation_gas( "type": "entity_unexpected_unit_gas", "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh", + "energy_units": ENERGY_UNITS_STRING, "gas_units": "CCF, ft³, m³, L", }, }, @@ -885,7 +879,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" + f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, From 0e09a47476e5d5e9f898fcde182394cc744f3c52 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:08:56 +0200 Subject: [PATCH 0086/1118] Add OpenAI AI Task entity (#148295) --- .../openai_conversation/__init__.py | 57 +-- .../components/openai_conversation/ai_task.py | 77 ++++ .../openai_conversation/config_flow.py | 88 +++-- .../components/openai_conversation/const.py | 13 + .../components/openai_conversation/entity.py | 99 +++-- .../openai_conversation/strings.json | 46 +++ .../openai_conversation/__init__.py | 240 ++++++++++++ .../openai_conversation/conftest.py | 125 ++++++- .../snapshots/test_init.ambr | 4 +- .../openai_conversation/test_ai_task.py | 124 +++++++ .../openai_conversation/test_config_flow.py | 127 ++++++- .../openai_conversation/test_conversation.py | 343 +----------------- .../openai_conversation/test_entity.py | 77 ++++ .../openai_conversation/test_init.py | 195 ++++++++-- 14 files changed, 1152 insertions(+), 463 deletions(-) create mode 100644 homeassistant/components/openai_conversation/ai_task.py create mode 100644 tests/components/openai_conversation/test_ai_task.py create mode 100644 tests/components/openai_conversation/test_entity.py diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 721ab44639f..77b71ae372d 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from types import MappingProxyType import openai from openai.types.images_response import ImagesResponse @@ -45,9 +46,11 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_AI_TASK_NAME, DEFAULT_NAME, DOMAIN, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -59,7 +62,7 @@ from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] @@ -153,28 +156,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EasyInputMessageParam(type="message", role="user", content=content) ] - try: - model_args = { - "model": model, - "input": messages, - "max_output_tokens": conversation_subentry.data.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": conversation_subentry.data.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - "user": call.context.user_id, - "store": False, + model_args = { + "model": model, + "input": messages, + "max_output_tokens": conversation_subentry.data.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": conversation_subentry.data.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": conversation_subentry.data.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) } - if model.startswith("o"): - model_args["reasoning"] = { - "effort": conversation_subentry.data.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - + try: response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: @@ -361,6 +364,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py new file mode 100644 index 00000000000..ff8c6e62520 --- /dev/null +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -0,0 +1,77 @@ +"""AI Task integration for OpenAI.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from .entity import OpenAIBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenAITaskEntity( + ai_task.AITaskEntity, + OpenAIBaseLLMEntity, +): + """OpenAI AI Task entity.""" + + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with OpenAI structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ae1e2f31a85..ce6872c7c20 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -55,9 +55,12 @@ from .const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, @@ -77,12 +80,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -99,7 +96,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -129,10 +126,16 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ], ) @@ -146,11 +149,14 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": OpenAISubentryFlowHandler, + "ai_task_data": OpenAISubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" +class OpenAISubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing OpenAI subentries.""" last_rendered_recommended = False options: dict[str, Any] @@ -164,7 +170,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Add a subentry.""" - self.options = RECOMMENDED_OPTIONS.copy() + if self._subentry_type == "ai_task_data": + self.options = RECOMMENDED_AI_TASK_OPTIONS.copy() + else: + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() return await self.async_step_init() async def async_step_reconfigure( @@ -181,6 +190,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): # abort if entry is not loaded if self._get_entry().state != ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") + options = self.options hass_apis: list[SelectOptionDict] = [ @@ -198,28 +208,32 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): step_schema: VolDictType = {} if self._is_new: - step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( - str + if self._subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + step_schema[vol.Required(CONF_NAME, default=default_name)] = str + + if self._subentry_type == "conversation": + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } ) - step_schema.update( - { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API): SelectSelector( - SelectSelectorConfig(options=hass_apis, multiple=True) - ), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } - ) + step_schema[ + vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)) + ] = bool if user_input is not None: if not user_input.get(CONF_LLM_HASS_API): @@ -320,7 +334,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if not model.startswith(tuple(UNSUPPORTED_WEB_SEARCH_MODELS)): + if self._subentry_type == "conversation" and not model.startswith( + tuple(UNSUPPORTED_WEB_SEARCH_MODELS) + ): step_schema.update( { vol.Optional( @@ -362,7 +378,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if not step_schema: if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( @@ -384,7 +400,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options.update(user_input) if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 6a6a5b2ce6e..777ded55657 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -2,10 +2,14 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" @@ -51,3 +55,12 @@ UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ "o1", "o3-mini", ] + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7351cbccbfa..97f3bd0ccfe 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -39,6 +39,7 @@ from openai.types.responses import ( ) from openai.types.responses.response_input_param import FunctionCallOutput from openai.types.responses.web_search_tool_param import UserLocation +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -47,6 +48,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, @@ -79,6 +81,47 @@ if TYPE_CHECKING: MAX_TOOL_ITERATIONS = 10 +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenAI API.""" + if schema["type"] == "object": + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + schema: vol.Schema, llm_api: llm.APIInstance | None +) -> dict[str, Any]: + """Format the schema to be compatible with OpenAI API.""" + result: dict[str, Any] = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result) + + result["strict"] = True + result["additionalProperties"] = False + return result + + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None ) -> FunctionToolParam: @@ -243,6 +286,8 @@ class OpenAIBaseLLMEntity(Entity): async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -273,39 +318,47 @@ class OpenAIBaseLLMEntity(Entity): tools = [] tools.append(web_search) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_args = { + "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + "input": [], + "max_output_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "store": False, + "stream": True, + } + if tools: + model_args["tools"] = tools + + if model_args["model"].startswith("o"): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False + messages = [ m for content in chat_log.content for m in _convert_content_to_param(content) ] + if structure and structure_name: + model_args["text"] = { + "format": { + "type": "json_schema", + "name": slugify(structure_name), + "schema": _format_structured_output(structure, chat_log.llm_api), + }, + } client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "store": False, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - model_args["include"] = ["reasoning.encrypted_content"] + model_args["input"] = messages try: result = await client.responses.create(**model_args) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index ffbe84337b7..5011fc9cf99 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -68,6 +68,52 @@ "error": { "model_not_supported": "This model is not supported, please select a different model" } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add Generate data with AI service", + "reconfigure": "Reconfigure Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::openai_conversation::config_subentries::conversation::step::init::data::recommended%]" + } + }, + "advanced": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]", + "temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]", + "top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]" + } + }, + "model": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]", + "data": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]" + }, + "data_description": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]" + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]" + }, + "error": { + "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]" + } } }, "selector": { diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index dda2fe16a63..11dc978250a 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1 +1,241 @@ """Tests for the OpenAI Conversation integration.""" + +from openai.types.responses import ( + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, +) +from openai.types.responses.response_function_web_search import ActionSearch + + +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + sequence_number=0, + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAA", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAABBB", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + + +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, + status="in_progress", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, + status="completed", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 628c1846e16..84c907a7c2e 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,13 +1,30 @@ """Tests helpers.""" +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponseOutputItemDoneEvent, + ResponseTextConfig, +) +from openai.types.responses.response import IncompleteDetails import pytest from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + RECOMMENDED_AI_TASK_OPTIONS, ) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API @@ -19,14 +36,14 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_subentry_data() -> dict[str, Any]: +def mock_conversation_subentry_data() -> dict[str, Any]: """Mock subentry data.""" return {} @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_subentry_data: dict[str, Any] + hass: HomeAssistant, mock_conversation_subentry_data: dict[str, Any] ) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( @@ -36,13 +53,20 @@ def mock_config_entry( "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ ConfigSubentryData( - data=mock_subentry_data, + data=mock_conversation_subentry_data, subentry_type="conversation", title=DEFAULT_CONVERSATION_NAME, unique_id=None, - ) + ), + ConfigSubentryData( + data=RECOMMENDED_AI_TASK_OPTIONS, + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), ], ) entry.add_to_hass(hass) @@ -91,3 +115,94 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools", []), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + sequence_number=0, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + sequence_number=0, + type="response.in_progress", + ) + response.status = "completed" + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + + yield value + + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + sequence_number=0, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + sequence_number=0, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + sequence_number=0, + type="response.completed", + ) + + with patch( + "openai.resources.responses.AsyncResponses.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0), **kwargs + ) + + yield mock_create diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index 8648e47474e..4eff869b016 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_devices[mock_subentry_data0] +# name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -26,7 +26,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[mock_subentry_data1] +# name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py new file mode 100644 index 00000000000..4541e11f5f8 --- /dev/null +++ b/tests/components/openai_conversation/test_ai_task.py @@ -0,0 +1,124 @@ +"""Test AI Task platform of OpenAI Conversation integration.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import create_message_item + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.openai_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="The test data", output_index=0) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task structured data generation.""" + # Mock the OpenAI response stream with JSON data + mock_create_stream.return_value = [ + create_message_item( + id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0 + ) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with invalid JSON response.""" + # Mock the OpenAI response stream with invalid JSON + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0) + ] + + with pytest.raises( + HomeAssistantError, match="Error with OpenAI structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index e845828570c..0ccbc39160a 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -8,7 +8,9 @@ from openai.types.responses import Response, ResponseOutputMessage, ResponseOutp import pytest from homeassistant import config_entries -from homeassistant.components.openai_conversation.config_flow import RECOMMENDED_OPTIONS +from homeassistant.components.openai_conversation.config_flow import ( + RECOMMENDED_CONVERSATION_OPTIONS, +) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, @@ -24,8 +26,10 @@ from homeassistant.components.openai_conversation.const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_P, @@ -77,10 +81,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -131,14 +141,14 @@ async def test_creating_conversation_subentry( result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {"name": "My Custom Agent", **RECOMMENDED_OPTIONS}, + {"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "My Custom Agent" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options @@ -709,3 +719,110 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + CONF_RECOMMENDED: True, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + CONF_RECOMMENDED: True, + } + + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_creating_ai_task_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry with advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + + # Go to advanced settings + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Advanced AI Task", + CONF_RECOMMENDED: False, + }, + ) + + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "advanced" + + # Configure advanced settings + result3 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + + assert result3.get("type") is FlowResultType.CREATE_ENTRY + assert result3.get("title") == "Advanced AI Task" + assert result3.get("data") == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 7a3bcb21768..39cd129e1ba 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,41 +1,15 @@ """Tests for the OpenAI integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import httpx from openai import AuthenticationError, RateLimitError -from openai.types import ResponseFormatText from openai.types.responses import ( - Response, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, ResponseError, ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseIncompleteEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningItem, ResponseStreamEvent, - ResponseTextConfig, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, - ResponseWebSearchCallCompletedEvent, - ResponseWebSearchCallInProgressEvent, - ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails -from openai.types.responses.response_function_web_search import ActionSearch import pytest from syrupy.assertion import SnapshotAssertion @@ -55,6 +29,13 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component +from . import ( + create_function_tool_call_item, + create_message_item, + create_reasoning_item, + create_web_search_item, +) + from tests.common import MockConfigEntry from tests.components.conversation import ( MockChatLog, @@ -62,97 +43,6 @@ from tests.components.conversation import ( ) -@pytest.fixture -def mock_create_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(events, **kwargs): - response = Response( - id="resp_A", - created_at=1700000000, - error=None, - incomplete_details=None, - instructions=kwargs.get("instructions"), - metadata=kwargs.get("metadata", {}), - model=kwargs.get("model", "gpt-4o-mini"), - object="response", - output=[], - parallel_tool_calls=kwargs.get("parallel_tool_calls", True), - temperature=kwargs.get("temperature", 1.0), - tool_choice=kwargs.get("tool_choice", "auto"), - tools=kwargs.get("tools"), - top_p=kwargs.get("top_p", 1.0), - max_output_tokens=kwargs.get("max_output_tokens", 100000), - previous_response_id=kwargs.get("previous_response_id"), - reasoning=kwargs.get("reasoning"), - status="in_progress", - text=kwargs.get( - "text", ResponseTextConfig(format=ResponseFormatText(type="text")) - ), - truncation=kwargs.get("truncation", "disabled"), - usage=None, - user=kwargs.get("user"), - store=kwargs.get("store", True), - ) - yield ResponseCreatedEvent( - response=response, - sequence_number=0, - type="response.created", - ) - yield ResponseInProgressEvent( - response=response, - sequence_number=0, - type="response.in_progress", - ) - response.status = "completed" - - for value in events: - if isinstance(value, ResponseOutputItemDoneEvent): - response.output.append(value.item) - elif isinstance(value, IncompleteDetails): - response.status = "incomplete" - response.incomplete_details = value - break - if isinstance(value, ResponseError): - response.status = "failed" - response.error = value - break - - yield value - - if isinstance(value, ResponseErrorEvent): - return - - if response.status == "incomplete": - yield ResponseIncompleteEvent( - response=response, - sequence_number=0, - type="response.incomplete", - ) - elif response.status == "failed": - yield ResponseFailedEvent( - response=response, - sequence_number=0, - type="response.failed", - ) - else: - yield ResponseCompletedEvent( - response=response, - sequence_number=0, - type="response.completed", - ) - - with patch( - "openai.resources.responses.AsyncResponses.create", - AsyncMock(), - ) as mock_create: - mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0), **kwargs - ) - - yield mock_create - - async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -347,225 +237,6 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -def create_message_item( - id: str, text: str | list[str], output_index: int -) -> list[ResponseStreamEvent]: - """Create a message item.""" - if isinstance(text, str): - text = [text] - - content = ResponseOutputText(annotations=[], text="", type="output_text") - events = [ - ResponseOutputItemAddedEvent( - item=ResponseOutputMessage( - id=id, - content=[], - type="message", - role="assistant", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseContentPartAddedEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.added", - ), - ] - - content.text = "".join(text) - events.extend( - ResponseTextDeltaEvent( - content_index=0, - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.output_text.delta", - ) - for delta in text - ) - - events.extend( - [ - ResponseTextDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - text="".join(text), - sequence_number=0, - type="response.output_text.done", - ), - ResponseContentPartDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.done", - ), - ResponseOutputItemDoneEvent( - item=ResponseOutputMessage( - id=id, - content=[content], - role="assistant", - status="completed", - type="message", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - ) - - return events - - -def create_function_tool_call_item( - id: str, arguments: str | list[str], call_id: str, name: str, output_index: int -) -> list[ResponseStreamEvent]: - """Create a function tool call item.""" - if isinstance(arguments, str): - arguments = [arguments] - - events = [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="", - call_id=call_id, - name=name, - type="function_call", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ) - ] - - events.extend( - ResponseFunctionCallArgumentsDeltaEvent( - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.delta", - ) - for delta in arguments - ) - - events.append( - ResponseFunctionCallArgumentsDoneEvent( - arguments="".join(arguments), - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.done", - ) - ) - - events.append( - ResponseOutputItemDoneEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="".join(arguments), - call_id=call_id, - name=name, - type="function_call", - status="completed", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ) - ) - - return events - - -def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a reasoning item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAA", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseOutputItemDoneEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAABBB", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - -def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a web search call item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionWebSearch( - id=id, - status="in_progress", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseWebSearchCallInProgressEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.in_progress", - ), - ResponseWebSearchCallSearchingEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.searching", - ), - ResponseWebSearchCallCompletedEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.completed", - ), - ResponseOutputItemDoneEvent( - item=ResponseFunctionWebSearch( - id=id, - status="completed", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - async def test_function_call( hass: HomeAssistant, mock_config_entry_with_reasoning_model: MockConfigEntry, diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py new file mode 100644 index 00000000000..58187bd63e9 --- /dev/null +++ b/tests/components/openai_conversation/test_entity.py @@ -0,0 +1,77 @@ +"""Tests for the OpenAI Conversation entity.""" + +import voluptuous as vol + +from homeassistant.components.openai_conversation.entity import ( + _format_structured_output, +) +from homeassistant.helpers import selector + + +async def test_format_structured_output() -> None: + """Test the format_structured_output function.""" + schema = vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ), + ), + vol.Required("stuff"): selector.ObjectSelector( + { + "multiple": True, + "fields": { + "item_name": { + "selector": {"text": None}, + }, + "item_value": { + "selector": {"text": None}, + }, + }, + } + ), + } + ) + assert _format_structured_output(schema, None) == { + "additionalProperties": False, + "properties": { + "age": { + "maximum": 120.0, + "minimum": 0.0, + "type": [ + "number", + "null", + ], + }, + "name": { + "type": "string", + }, + "stuff": { + "items": { + "properties": { + "item_name": { + "type": ["string", "null"], + }, + "item_value": { + "type": ["string", "null"], + }, + }, + "required": [ + "item_name", + "item_value", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "stuff", + "age", + ], + "strict": True, + "type": "object", + } diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3e13cb3dd1c..7af1151075c 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -17,7 +17,10 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL -from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.components.openai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -534,7 +537,7 @@ async def test_generate_content_service_error( ) -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -582,17 +585,33 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 - subentry = next(iter(mock_config_entry.subentries.values())) - assert subentry.unique_id is None - assert subentry.title == "ChatGPT" - assert subentry.subentry_type == "conversation" - assert subentry.data == OPTIONS + # Find the conversation subentry + conversation_subentry = None + ai_task_subentry = None + for subentry in mock_config_entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + elif subentry.subentry_type == "ai_task_data": + ai_task_subentry = subentry + assert conversation_subentry is not None + assert conversation_subentry.unique_id is None + assert conversation_subentry.title == "ChatGPT" + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == OPTIONS + + assert ai_task_subentry is not None + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.subentry_type == "ai_task_data" + + # Use conversation subentry for the rest of the assertions + subentry = conversation_subentry migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -617,12 +636,12 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_keys( +async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different API keys.""" + """Test migration from version 1 with different API keys.""" # Create two v1 config entries with different API keys options = { "recommended": True, @@ -695,28 +714,38 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 1 - subentry = list(entry.subentries.values())[0] - assert subentry.subentry_type == "conversation" - assert subentry.data == options - assert subentry.title == f"ChatGPT {idx + 1}" + assert len(entry.subentries) == 2 + + conversation_subentry = None + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + break + + assert conversation_subentry is not None + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == options + assert conversation_subentry.title == f"ChatGPT {idx + 1}" + + # Use conversation subentry for device assertions + subentry = conversation_subentry dev = device_registry.async_get_device( - identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None assert dev.config_entries == {entry.entry_id} assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_keys( +async def test_migration_from_v1_with_same_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + """Test migration from version 1 with same API keys consolidates entries.""" # Create two v1 config entries with the same API key options = { "recommended": True, @@ -790,17 +819,28 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 2 # Two subentries from the two original entries + assert ( + len(entry.subentries) == 3 + ) # Two conversation subentries + one AI task subentry - # Check both subentries exist with correct data - subentries = list(entry.subentries.values()) - titles = [sub.title for sub in subentries] + # Check both conversation subentries exist with correct data + conversation_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "conversation" + ] + ai_task_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "ai_task_data" + ] + + assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 + + titles = [sub.title for sub in conversation_subentries] assert "ChatGPT" in titles assert "ChatGPT 2" in titles - for subentry in subentries: + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -815,12 +855,12 @@ async def test_migration_from_v1_to_v2_with_same_keys( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -913,16 +953,22 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "ChatGPT" - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 # 2 conversation + 1 AI task conversation_subentries = [ subentry for subentry in entry.subentries.values() if subentry.subentry_type == "conversation" ] + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -972,7 +1018,9 @@ async def test_migration_from_v2_1_to_v2_2( } -@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +@pytest.mark.parametrize( + "mock_conversation_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}] +) async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -980,12 +1028,89 @@ async def test_devices( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Assert exception when invalid config entry is provided.""" + """Test devices are correctly created for subentries.""" devices = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) - assert len(devices) == 1 + assert len(devices) == 2 # One for conversation, one for AI task + + # Use the first device for snapshot comparison device = devices[0] assert device == snapshot(exclude=props("identifiers")) - subentry = next(iter(mock_config_entry.subentries.values())) - assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + # Verify the device has identifiers matching one of the subentries + expected_identifiers = [ + {(DOMAIN, subentry.subentry_id)} + for subentry in mock_config_entry.subentries.values() + ] + assert device.identifiers in expected_identifiers + + +async def test_migration_from_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with a conversation subentry + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=2, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + + # Check conversation subentry is still there + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 1 + conversation_subentry = conversation_subentries[0] + assert conversation_subentry.data == options + + # Check AI Task subentry was added + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + ai_task_subentry = ai_task_subentries[0] + assert ai_task_subentry.data == {"recommended": True} + assert ai_task_subentry.title == "OpenAI AI Task" From 6eeec948a8d64d458f4bc988343d9de183a38114 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 10 Jul 2025 23:09:47 +0200 Subject: [PATCH 0087/1118] Update frontend to 20250702.2 (#148573) --- 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 748d8f0c6f0..a7582ebc5e2 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==20250702.1"] + "requirements": ["home-assistant-frontend==20250702.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b73a458b7ec..52cfa22212d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b4a53c7dba7..dd5108a807a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3173b1443b6..4cd94a3f6ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 18a89d58156de33233120f995a166490cd53c223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Jul 2025 11:10:48 -1000 Subject: [PATCH 0088/1118] Bump aiohttp to 3.12.14 (#148565) --- homeassistant/components/http/ban.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 7e55191639b..71f3d54bef6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) # type: ignore[arg-type] + app.on_startup.append(ban_startup) @middleware diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52cfa22212d..89ff2238f61 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 3841d234ddf..3ea2a9c9f1b 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.1", - "aiohttp==3.12.13", + "aiohttp==3.12.14", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index c246af65758..118d2bedfa6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.1 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From a2220cc2e657bab50dba5bfa1408df3886c84bab Mon Sep 17 00:00:00 2001 From: Harry Heymann Date: Thu, 10 Jul 2025 17:36:51 -0400 Subject: [PATCH 0089/1118] Add LED intensity custom attributes for Matter Inovelli Dimmers (#148074) Co-authored-by: Norbert Rittel --- homeassistant/components/matter/number.py | 32 +++++ homeassistant/components/matter/strings.json | 6 + .../matter/snapshots/test_number.ambr | 114 ++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index c948f39834a..ea348c20012 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -339,4 +339,36 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.MaxTemperature, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOff", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_off", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOff, + ), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOn", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_on", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6d167e4136e..20d7eb69ba4 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -194,6 +194,12 @@ }, "auto_relock_timer": { "name": "Autorelock time" + }, + "led_indicator_intensity_off": { + "name": "LED off intensity" + }, + "led_indicator_intensity_on": { + "name": "LED on intensity" } }, "light": { diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 8d27c4b4691..da709615610 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -750,6 +750,120 @@ 'state': 'unavailable', }) # --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_off_intensity', + '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': 'LED off intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_off', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOff-305134641-305070178', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED off intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_off_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_on_intensity', + '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': 'LED on intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_on', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOn-305134641-305070177', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED on intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_on_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33', + }) +# --- # name: test_numbers[multi_endpoint_light][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 19b3b6cb2821c030648a3cbf2010134bd915fc09 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:45:11 +0200 Subject: [PATCH 0090/1118] Add attachment support to Google Gemini (#148208) --- .../ai_task.py | 7 +- .../entity.py | 26 ++++- .../test_ai_task.py | 96 ++++++++++++++++++- .../test_init.py | 1 - 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index b4f9d73e38d..80d5a1dfa06 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -37,7 +37,10 @@ class GoogleGenerativeAITaskEntity( ): """Google Generative AI AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, @@ -45,7 +48,7 @@ class GoogleGenerativeAITaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure) + await self._async_handle_chat_log(chat_log, task.structure, task.attachments) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 8f8edea18cb..fce1fdd40e7 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -8,7 +8,7 @@ from collections.abc import AsyncGenerator, Callable from dataclasses import replace import mimetypes from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from google.genai import Client from google.genai.errors import APIError, ClientError @@ -30,8 +30,8 @@ from google.genai.types import ( import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm @@ -60,6 +60,9 @@ from .const import ( TIMEOUT_MILLIS, ) +if TYPE_CHECKING: + from . import GoogleGenerativeAIConfigEntry + # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -313,7 +316,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): def __init__( self, - entry: ConfigEntry, + entry: GoogleGenerativeAIConfigEntry, subentry: ConfigSubentry, default_model: str = RECOMMENDED_CHAT_MODEL, ) -> None: @@ -335,6 +338,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, + attachments: list[ai_task.PlayMediaWithId] | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -438,6 +442,18 @@ class GoogleGenerativeAILLMBaseEntity(Entity): user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) chat_request: str | list[Part] = user_message.content + if attachments: + if any(a.path is None for a in attachments): + raise HomeAssistantError( + "Only local attachments are currently supported" + ) + files = await async_prepare_files_for_prompt( + self.hass, + self._genai_client, + [a.path for a in attachments], # type: ignore[misc] + ) + chat_request = [chat_request, *files] + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -508,7 +524,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): async def async_prepare_files_for_prompt( hass: HomeAssistant, client: Client, files: list[Path] ) -> list[File]: - """Append files to a prompt. + """Upload files so they can be attached to a prompt. Caller needs to ensure that the files are allowed. """ diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index b2b44aa1cd6..653b41fcb6e 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -1,12 +1,13 @@ """Test AI Task platform of Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock +from pathlib import Path +from unittest.mock import AsyncMock, patch -from google.genai.types import GenerateContentResponse +from google.genai.types import File, FileState, GenerateContentResponse import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -64,6 +65,93 @@ async def test_generate_data( ) assert result.data == "Hi there!" + # Test with attachments + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + file1 = File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE) + file2 = File(name="context.txt", state=FileState.ACTIVE) + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "google.genai.files.Files.upload", + side_effect=[file1, file2], + ) as mock_upload, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + outgoing_message = mock_send_message_stream.mock_calls[1][2]["message"] + assert outgoing_message == ["Test prompt", file1, file2] + + assert result.data == "Hi there!" + assert len(mock_upload.mock_calls) == 2 + assert mock_upload.mock_calls[0][2]["file"] == Path("doorbell_snapshot.jpg") + assert mock_upload.mock_calls[1][2]["file"] == Path("context.txt") + + # Test attachments require play media with a path + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=None, + ), + ], + ), + pytest.raises( + HomeAssistantError, match="Only local attachments are currently supported" + ), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + # Test with structure mock_send_message_stream.return_value = [ [ GenerateContentResponse( @@ -97,7 +185,7 @@ async def test_generate_data( ) assert result.data == {"characters": ["Mario", "Luigi"]} - assert len(mock_chat_create.mock_calls) == 2 + assert len(mock_chat_create.mock_calls) == 4 config = mock_chat_create.mock_calls[-1][2]["config"] assert config.response_mime_type == "application/json" assert config.response_schema == { diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 351895c89fb..351293e7ac0 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -87,7 +87,6 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), - patch("builtins.open", mock_open(read_data="this is an image")), patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( From e6702d2392fe0f47cddcf0e31e1f56ae94182298 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:45:56 +0200 Subject: [PATCH 0091/1118] Serialize Object Selector correctly if a field is required (#148577) --- homeassistant/helpers/llm.py | 14 ++++++++++---- tests/helpers/test_llm.py | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b239ad99119..784288375e9 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -779,13 +779,19 @@ def selector_serializer(schema: Any) -> Any: # noqa: C901 if isinstance(schema, selector.ObjectSelector): result = {"type": "object"} if fields := schema.config.get("fields"): - result["properties"] = { - field: convert( + properties = {} + required = [] + for field, field_schema in fields.items(): + properties[field] = convert( selector.selector(field_schema["selector"]), custom_serializer=selector_serializer, ) - for field, field_schema in fields.items() - } + if field_schema.get("required"): + required.append(field) + result["properties"] = properties + + if required: + result["required"] = required else: result["additionalProperties"] = True if schema.config.get("multiple"): diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 78ff675f0b6..9ba93cef4ca 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1161,6 +1161,7 @@ async def test_selector_serializer( "name": {"type": "string"}, "percentage": {"type": "number", "minimum": 30, "maximum": 100}, }, + "required": ["name"], } assert selector_serializer( selector.ObjectSelector( @@ -1190,6 +1191,7 @@ async def test_selector_serializer( "maximum": 100, }, }, + "required": ["name"], }, } assert selector_serializer( From 193b32218f8398fd88c3e94dddcbf210def07022 Mon Sep 17 00:00:00 2001 From: jlestel Date: Fri, 11 Jul 2025 01:41:03 +0200 Subject: [PATCH 0092/1118] Fix domain validation in Tesla Fleet (#148555) --- homeassistant/components/tesla_fleet/config_flow.py | 4 +++- tests/components/tesla_fleet/test_config_flow.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index ac55a380abb..48eb736ae56 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -226,5 +226,7 @@ class OAuth2FlowHandler( def _is_valid_domain(self, domain: str) -> bool: """Validate domain format.""" # Basic domain validation regex - domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + domain_pattern = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" + ) return bool(domain_pattern.match(domain)) diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 4a8142a2d85..98806a27268 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -713,8 +713,11 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("domain", "expected_valid"), [ ("example.com", True), + ("exa-mple.com", True), ("test.example.com", True), + ("tes-t.example.com", True), ("sub.domain.example.org", True), + ("su-b.dom-ain.exam-ple.org", True), ("https://example.com", False), ("invalid-domain", False), ("", False), @@ -722,6 +725,8 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("example.", False), (".example.com", False), ("exam ple.com", False), + ("-example.com", False), + ("domain-.example.com", False), ], ) def test_is_valid_domain(domain: str, expected_valid: bool) -> None: From c6c622797d74b3e2a09f2c199e586272c75c8532 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 11 Jul 2025 13:55:13 +0800 Subject: [PATCH 0093/1118] Add YoLink YS7A12 support (#148588) --- homeassistant/components/yolink/binary_sensor.py | 8 ++++++-- homeassistant/components/yolink/sensor.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 7f965650354..d57e942734e 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -12,6 +12,7 @@ from yolink.const import ( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ) @@ -53,6 +54,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ] @@ -90,8 +92,10 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="smoke_detected", device_class=BinarySensorDeviceClass.SMOKE, - value=lambda state: state.get("smokeAlarm"), - exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, + value=lambda state: state.get("smokeAlarm") is True + or state.get("denseSmokeAlarm") is True, + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_SMOKE_ALARM], ), YoLinkBinarySensorEntityDescription( key="pipe_leak_detected", diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 2845f8ee533..37cd763194d 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,7 @@ from yolink.const import ( ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, @@ -106,6 +107,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] BATTERY_POWER_SENSOR = [ @@ -126,12 +128,14 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] MCU_DEV_TEMPERATURE_SENSOR = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] NONE_HUMIDITY_SENSOR_MODELS = [ From 32121a073c97cc8c95f4d6892d7428c96654e936 Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Fri, 11 Jul 2025 07:56:23 +0200 Subject: [PATCH 0094/1118] Add release URL for Tessie updates (#148548) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tessie/update.py | 7 +++++++ tests/components/tessie/snapshots/test_update.ambr | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index e9af673b1f4..cd3c3b32857 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -88,6 +88,13 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): return self.get("vehicle_state_software_update_install_perc") return None + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version is None: + return None + return f"https://stats.tessie.com/versions/{self.latest_version}" + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 8780f64bb09..ff298f97ecd 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'installed_version': '2023.38.6', 'latest_version': '2023.44.30.4', 'release_summary': None, - 'release_url': None, + 'release_url': 'https://stats.tessie.com/versions/2023.44.30.4', 'skipped_version': None, 'supported_features': , 'title': None, From cd73824e3e42390920e823f1480fe7792dca6571 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 11 Jul 2025 09:06:18 +0200 Subject: [PATCH 0095/1118] Ensure response is fully read to prevent premature connection closure in rest command (#148532) --- .../components/rest_command/__init__.py | 5 ++++ tests/components/rest_command/test_init.py | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 0a9632b864d..0ea5fc60472 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -178,6 +178,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not service.return_response: + # always read the response to avoid closing the connection + # before the server has finished sending it, while avoiding excessive memory usage + async for _ in response.content.iter_chunked(1024): + pass + return None _content = None diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 5549aa67815..b9c1096f26a 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -328,7 +328,7 @@ async def test_rest_command_get_response_malformed_json( aioclient_mock.get( TEST_URL, - content='{"status": "failure", 42', + content=b'{"status": "failure", 42', headers={"content-type": "application/json"}, ) @@ -381,3 +381,27 @@ async def test_rest_command_get_response_none( ) assert not response + + +async def test_rest_command_response_iter_chunked( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Ensure response is consumed when return_response is False.""" + await setup_component() + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + aioclient_mock.get(TEST_URL, content=png) + + with patch("aiohttp.StreamReader.iter_chunked", autospec=True) as mock_iter_chunked: + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + # Ensure the response is not returned + assert response is None + + # Verify iter_chunked was called with a chunk size + assert mock_iter_chunked.called From 5a4c8373282503a40623120aaeb163c14369dcef Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 11 Jul 2025 11:19:54 +0200 Subject: [PATCH 0096/1118] Fix entity_id should be based on object_id the first time an entity is added (#148484) --- homeassistant/components/mqtt/entity.py | 35 ++++++++++++------- tests/components/mqtt/test_discovery.py | 46 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 338779f32cb..f1594a7b034 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -389,16 +389,6 @@ def async_setup_entity_entry_helper( _async_setup_entities() -def init_entity_id_from_config( - hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str -) -> None: - """Set entity_id from object_id if defined in config.""" - if CONF_OBJECT_ID in config: - entity.entity_id = async_generate_entity_id( - entity_id_format, config[CONF_OBJECT_ID], None, hass - ) - - class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -1312,6 +1302,7 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str + _update_registry_entity_id: str | None = None def __init__( self, @@ -1346,13 +1337,33 @@ class MqttEntity( def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" - init_entity_id_from_config( - self.hass, self, self._config, self._entity_id_format + if CONF_OBJECT_ID not in self._config: + return + self.entity_id = async_generate_entity_id( + self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass ) + if self.unique_id is None: + return + # Check for previous deleted entities + entity_registry = er.async_get(self.hass) + entity_platform = self._entity_id_format.split(".")[0] + if ( + deleted_entry := entity_registry.deleted_entities.get( + (entity_platform, DOMAIN, self.unique_id) + ) + ) and deleted_entry.entity_id != self.entity_id: + # Plan to update the entity_id basis on `object_id` if a deleted entity was found + self._update_registry_entity_id = self.entity_id @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" + if self._update_registry_entity_id is not None: + entity_registry = er.async_get(self.hass) + entity_registry.async_update_entity( + self.entity_id, new_entity_id=self._update_registry_entity_id + ) + await super().async_added_to_hass() self._subscriptions = {} self._prepare_subscribe_topics() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 35a9a0494a6..04b4bda0d79 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1496,6 +1496,52 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered +async def test_discovery_with_object_id_for_previous_deleted_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test discovering an MQTT entity with object_id and unique_id.""" + + topic = "homeassistant/sensor/object/bla/config" + config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "hello_id", "state_topic": "test-topic" }' + ) + new_config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + ) + initial_entity_id = "sensor.hello_id" + new_entity_id = "sensor.updated_hello_id" + name = "Hello World 11" + domain = "sensor" + + await mqtt_mock_entry() + async_fire_mqtt_message(hass, topic, config) + await hass.async_block_till_done() + + state = hass.states.get(initial_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + # Delete the entity + async_fire_mqtt_message(hass, topic, "") + await hass.async_block_till_done() + assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered + + # Rediscover with new object_id + async_fire_mqtt_message(hass, topic, new_config) + await hass.async_block_till_done() + + state = hass.states.get(new_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + async def test_discovery_incl_nodeid( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: From 22828568e2831031a85b10a94505f6d030881889 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 11 Jul 2025 11:37:24 +0200 Subject: [PATCH 0097/1118] Wallbox Integration - Type Config Entry (#148594) --- homeassistant/components/wallbox/__init__.py | 12 +++++++----- homeassistant/components/wallbox/coordinator.py | 6 ++++-- homeassistant/components/wallbox/lock.py | 5 ++--- homeassistant/components/wallbox/number.py | 7 +++---- homeassistant/components/wallbox/select.py | 5 ++--- homeassistant/components/wallbox/sensor.py | 5 ++--- homeassistant/components/wallbox/switch.py | 5 ++--- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index c2983d540df..43b5d3ef91f 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -4,13 +4,17 @@ from __future__ import annotations from wallbox import Wallbox -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from .const import UPDATE_INTERVAL -from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input +from .coordinator import ( + InvalidAuth, + WallboxConfigEntry, + WallboxCoordinator, + async_validate_input, +) PLATFORMS = [ Platform.LOCK, @@ -20,8 +24,6 @@ PLATFORMS = [ Platform.SWITCH, ] -type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Set up Wallbox from a config entry.""" @@ -45,6 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index ffd235157ac..82a807e4d09 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -77,6 +77,8 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } +type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] + def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], @@ -118,10 +120,10 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - config_entry: ConfigEntry + config_entry: WallboxConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + self, hass: HomeAssistant, config_entry: WallboxConfigEntry, wallbox: Wallbox ) -> None: """Initialize.""" self._station = config_entry.data[CONF_STATION] diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 6ba9058db96..f48ac000110 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,7 +13,7 @@ from .const import ( CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -27,7 +26,7 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index af4fbe2c38b..6bc37778a61 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,7 +23,7 @@ from .const import ( CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -79,7 +78,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" @@ -103,7 +102,7 @@ class WallboxNumber(WallboxEntity, NumberEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, + entry: WallboxConfigEntry, description: WallboxNumberEntityDescription, ) -> None: """Initialize a Wallbox number entity.""" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 10ac4e61189..8d4cf252344 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from requests import HTTPError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,7 +22,7 @@ from .const import ( DOMAIN, EcoSmartMode, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -58,7 +57,7 @@ SELECT_TYPES: dict[str, WallboxSelectEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox select entities in HASS.""" diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 7d5e5b56309..b59e1e5319d 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfElectricCurrent, @@ -44,7 +43,7 @@ from .const import ( CHARGER_STATE_OF_CHARGE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -169,7 +168,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 7a28f863c4d..74f1783f539 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,7 +15,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, ChargerStatus, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { @@ -29,7 +28,7 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" From 0b2ce73eac477659d3dd2a502b6313fe3757411e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 11 Jul 2025 11:43:29 +0200 Subject: [PATCH 0098/1118] Fix description of `html5.dismiss` action (#148591) --- homeassistant/components/html5/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 2c68223581a..ee844f320bc 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -29,7 +29,7 @@ "services": { "dismiss": { "name": "Dismiss", - "description": "Dismisses a html5 notification.", + "description": "Dismisses an HTML5 notification.", "fields": { "target": { "name": "Target", From 87aecf0ed966039609932d09916e560e18d4e3c5 Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:45:21 +0200 Subject: [PATCH 0099/1118] Linkplay: add select entity to set Audio Output hardware (#143329) --- homeassistant/components/linkplay/const.py | 2 +- homeassistant/components/linkplay/icons.json | 5 + homeassistant/components/linkplay/select.py | 112 ++++++++++++++++++ .../components/linkplay/strings.json | 11 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/linkplay/select.py diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index 74b87f4aae9..ec85e5af97c 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -19,5 +19,5 @@ class LinkPlaySharedData: DOMAIN = "linkplay" SHARED_DATA = "shared_data" SHARED_DATA_KEY: HassKey[LinkPlaySharedData] = HassKey(SHARED_DATA) -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SELECT] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json index c0fe86d9ac7..26f7202943f 100644 --- a/homeassistant/components/linkplay/icons.json +++ b/homeassistant/components/linkplay/icons.json @@ -4,6 +4,11 @@ "timesync": { "default": "mdi:clock" } + }, + "select": { + "audio_output_hardware_mode": { + "default": "mdi:transit-connection-horizontal" + } } }, "services": { diff --git a/homeassistant/components/linkplay/select.py b/homeassistant/components/linkplay/select.py new file mode 100644 index 00000000000..ebf5a05512a --- /dev/null +++ b/homeassistant/components/linkplay/select.py @@ -0,0 +1,112 @@ +"""Support for LinkPlay select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from linkplay.bridge import LinkPlayBridge, LinkPlayPlayer +from linkplay.consts import AudioOutputHwMode +from linkplay.manufacturers import MANUFACTURER_WIIM + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LinkPlayConfigEntry +from .entity import LinkPlayBaseEntity, exception_wrap + +_LOGGER = logging.getLogger(__name__) + +AUDIO_OUTPUT_HW_MODE_MAP: dict[AudioOutputHwMode, str] = { + AudioOutputHwMode.OPTICAL: "optical", + AudioOutputHwMode.LINE_OUT: "line_out", + AudioOutputHwMode.COAXIAL: "coaxial", + AudioOutputHwMode.HEADPHONES: "headphones", +} + +AUDIO_OUTPUT_HW_MODE_MAP_INV: dict[str, AudioOutputHwMode] = { + v: k for k, v in AUDIO_OUTPUT_HW_MODE_MAP.items() +} + + +async def _get_current_option(bridge: LinkPlayBridge) -> str: + """Get the current hardware mode.""" + modes = await bridge.player.get_audio_output_hw_mode() + return AUDIO_OUTPUT_HW_MODE_MAP[modes.hardware] + + +@dataclass(frozen=True, kw_only=True) +class LinkPlaySelectEntityDescription(SelectEntityDescription): + """Class describing LinkPlay select entities.""" + + set_option_fn: Callable[[LinkPlayPlayer, str], Coroutine[Any, Any, None]] + current_option_fn: Callable[[LinkPlayPlayer], Awaitable[str]] + + +SELECT_TYPES_WIIM: tuple[LinkPlaySelectEntityDescription, ...] = ( + LinkPlaySelectEntityDescription( + key="audio_output_hardware_mode", + translation_key="audio_output_hardware_mode", + current_option_fn=_get_current_option, + set_option_fn=( + lambda linkplay_bridge, + option: linkplay_bridge.player.set_audio_output_hw_mode( + AUDIO_OUTPUT_HW_MODE_MAP_INV[option] + ) + ), + options=list(AUDIO_OUTPUT_HW_MODE_MAP_INV), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LinkPlayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LinkPlay select from config entry.""" + + # add entities + if config_entry.runtime_data.bridge.device.manufacturer == MANUFACTURER_WIIM: + async_add_entities( + LinkPlaySelect(config_entry.runtime_data.bridge, description) + for description in SELECT_TYPES_WIIM + ) + + +class LinkPlaySelect(LinkPlayBaseEntity, SelectEntity): + """Representation of LinkPlay select.""" + + entity_description: LinkPlaySelectEntityDescription + + def __init__( + self, + bridge: LinkPlayPlayer, + description: LinkPlaySelectEntityDescription, + ) -> None: + """Initialize LinkPlay select.""" + super().__init__(bridge) + self.entity_description = description + self._attr_unique_id = f"{bridge.device.uuid}-{description.key}" + + async def async_update(self) -> None: + """Get the current value from the device.""" + try: + # modes = await self.entity_description.current_option_fn(self._bridge) + self._attr_current_option = await self.entity_description.current_option_fn( + self._bridge + ) + + except ValueError as ex: + _LOGGER.debug( + "Cannot retrieve hardware mode value from device with error:, %s", ex + ) + self._attr_current_option = None + + @exception_wrap + async def async_select_option(self, option: str) -> None: + """Set the option.""" + await self.entity_description.set_option_fn(self._bridge, option) diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 5d68754879c..7b0a6cbefe1 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -40,6 +40,17 @@ "timesync": { "name": "Sync time" } + }, + "select": { + "audio_output_hardware_mode": { + "name": "Audio output hardware mode", + "state": { + "optical": "Optical", + "line_out": "Line out", + "coaxial": "Coaxial", + "headphones": "Headphones" + } + } } }, "exceptions": { From ec5991bc686c3ed74185bb581abe4cc84ce0ab1e Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 11 Jul 2025 21:42:50 +1000 Subject: [PATCH 0100/1118] Add support for LIFX 26"x13" Ceiling (#148459) Signed-off-by: Avi Miller --- homeassistant/components/lifx/const.py | 1 + homeassistant/components/lifx/coordinator.py | 50 +- tests/components/lifx/__init__.py | 11 + .../lifx/snapshots/test_diagnostics.ambr | 1276 +++++++++++++++++ tests/components/lifx/test_diagnostics.py | 100 ++ 5 files changed, 1437 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index ecc572aa006..f0505f9a4fd 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -70,6 +70,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} +LIFX_128ZONE_CEILING_PRODUCT_IDS = {201, 202} _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 79ce843b339..c96f53d8f77 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -41,6 +41,7 @@ from .const import ( DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, + LIFX_128ZONE_CEILING_PRODUCT_IDS, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME, MESSAGE_RETRIES, @@ -183,6 +184,11 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return true if this is a matrix device.""" return bool(lifx_features(self.device)["matrix"]) + @cached_property + def is_128zone_matrix(self) -> bool: + """Return true if this is a 128-zone matrix device.""" + return bool(self.device.product in LIFX_128ZONE_CEILING_PRODUCT_IDS) + async def diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the device.""" features = lifx_features(self.device) @@ -216,6 +222,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): "last_result": self.device.last_hev_cycle_result, } + if features["matrix"] is True: + device_data["matrix"] = { + "effect": self.device.effect, + "chain": self.device.chain, + "chain_length": self.device.chain_length, + "tile_devices": self.device.tile_devices, + "tile_devices_count": self.device.tile_devices_count, + "tile_device_width": self.device.tile_device_width, + } + if features["infrared"] is True: device_data["infrared"] = {"brightness": self.device.infrared_brightness} @@ -291,6 +307,37 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): return calls + @callback + def _async_build_get64_update_requests(self) -> list[Callable]: + """Build one or more get64 update requests.""" + if self.device.tile_device_width == 0: + return [] + + calls: list[Callable] = [] + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=0, + width=self.device.tile_device_width, + ) + ) + if self.is_128zone_matrix: + # For 128-zone ceiling devices, we need another get64 request for the next set of zones + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=4, + width=self.device.tile_device_width, + ) + ) + return calls + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" device = self.device @@ -312,9 +359,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): [ self.device.get_tile_effect, self.device.get_device_chain, - self.device.get64, ] ) + methods.extend(self._async_build_get64_update_requests()) if self.is_extended_multizone: methods.append(self.device.get_extended_color_zones) elif self.is_legacy_multizone: @@ -339,6 +386,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] + if self.is_legacy_multizone and num_zones != self.get_number_of_zones(): # The number of zones has changed so we need # to update the zones again. This happens rarely. diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 81b913da6ce..95f6154030b 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -199,6 +199,17 @@ def _mocked_ceiling() -> Light: return bulb +def _mocked_128zone_ceiling() -> Light: + bulb = _mocked_bulb() + bulb.product = 201 # LIFX 26"x13" Ceiling + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + bulb.get64 = MockLifxCommand(bulb) + bulb.get_device_chain = MockLifxCommand(bulb) + return bulb + + def _mocked_bulb_old_firmware() -> Light: bulb = _mocked_bulb() bulb.host_firmware_version = "2.77" diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr index 82499c3632e..3e095252159 100644 --- a/tests/components/lifx/snapshots/test_diagnostics.ambr +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -1,4 +1,834 @@ # serializer version: 1 +# name: test_128zone_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 16, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 201, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 16, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 201, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- # name: test_bulb_diagnostics dict({ 'data': dict({ @@ -199,6 +1029,452 @@ }), }) # --- +# name: test_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 8, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 176, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 8, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 176, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- # name: test_multizone_bulb_diagnostics dict({ 'data': dict({ diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 5883ac046e7..830dc26829a 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -12,7 +12,9 @@ from . import ( IP_ADDRESS, SERIAL, MockLifxCommand, + _mocked_128zone_ceiling, _mocked_bulb, + _mocked_ceiling, _mocked_clean_bulb, _mocked_infrared_bulb, _mocked_light_strip, @@ -209,3 +211,101 @@ async def test_multizone_bulb_diagnostics( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == snapshot + + +async def test_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 8 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 8, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 176, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 64} + bulb.chain_length = 1 + + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot + + +async def test_128zone_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_128zone_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 16 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 16, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 201, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 128} + bulb.chain_length = 1 + + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot From 73c9d99abf11e88d3ef65f5b856711c19cc81a55 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:55:01 +0200 Subject: [PATCH 0101/1118] Add tuya snapshot tests for wxkg category (#148609) --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/wxkg_wireless_switch.json | 50 ++++++++ .../components/tuya/snapshots/test_event.ambr | 119 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 ++++++++ tests/components/tuya/test_event.py | 57 +++++++++ 5 files changed, 284 insertions(+) create mode 100644 tests/components/tuya/fixtures/wxkg_wireless_switch.json create mode 100644 tests/components/tuya/snapshots/test_event.ambr create mode 100644 tests/components/tuya/test_event.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index bf8af8835cf..90a49fc2372 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -88,6 +88,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, ], + "wxkg_wireless_switch": [ + # https://github.com/home-assistant/core/issues/93975 + Platform.EVENT, + Platform.SENSOR, + ], "zndb_smart_meter": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/wxkg_wireless_switch.json b/tests/components/tuya/fixtures/wxkg_wireless_switch.json new file mode 100644 index 00000000000..376276099cc --- /dev/null +++ b/tests/components/tuya/fixtures/wxkg_wireless_switch.json @@ -0,0 +1,50 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "44", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Smart Switch", + "model": "LKWSW201", + "category": "wxkg", + "product_id": "l8yaz4um5b3pwyvf", + "product_name": "Wireless Switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-01-05T20:12:39+00:00", + "create_time": "2023-01-05T20:12:39+00:00", + "update_time": "2023-05-30T17:17:47+00:00", + "function": {}, + "status_range": { + "switch_mode1": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "switch_mode2": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_mode1": "click", + "switch_mode2": "click", + "battery_percentage": 100 + } +} diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr new file mode 100644 index 00000000000..085ebd3ec8b --- /dev/null +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_1', + '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 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.mocked_device_idswitch_mode1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 1', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_2', + '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 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.mocked_device_idswitch_mode2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 2', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 5e52c0e063c..3704aa4f067 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1467,6 +1467,59 @@ 'state': '18.5', }) # --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_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.bathroom_smart_switch_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.mocked_device_idbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bathroom Smart Switch Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bathroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py new file mode 100644 index 00000000000..3a332dbe5c7 --- /dev/null +++ b/tests/components/tuya/test_event.py @@ -0,0 +1,57 @@ +"""Test Tuya event platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From a34264f345ff6445ccfc3c57976eb9691a183910 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Jul 2025 14:01:11 +0200 Subject: [PATCH 0102/1118] Add SmartThings RVC fixture (#148552) --- tests/components/smartthings/conftest.py | 1 + .../device_status/da_rvc_map_01011.json | 994 ++++++++++++++++++ .../fixtures/devices/da_rvc_map_01011.json | 353 +++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_select.ambr | 57 + .../smartthings/snapshots/test_sensor.ambr | 534 ++++++++++ .../smartthings/snapshots/test_switch.ambr | 48 + 7 files changed, 2020 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json create mode 100644 tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index e8cde67122b..93f505872f4 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -130,6 +130,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_wm_sc_000001", "da_rvc_normal_000001", + "da_rvc_map_01011", "da_ks_microwave_0101x", "da_ks_cooktop_31001", "da_ks_range_0101x", diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json new file mode 100644 index 00000000000..14244935308 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -0,0 +1,994 @@ +{ + "components": { + "refill-drainage-kit": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "samsungce.activationState": { + "activationState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.drainFilter", + "samsungce.connectionState", + "samsungce.activationState" + ], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.drainFilter": { + "drainFilterUsageStep": { + "value": null + }, + "drainFilterStatus": { + "value": null + }, + "drainFilterLastResetDate": { + "value": null + }, + "drainFilterResetType": { + "value": null + }, + "drainFilterUsage": { + "value": null + } + } + }, + "station": { + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerDustBag": { + "supportedStatus": { + "value": ["full", "normal"], + "timestamp": "2025-07-02T04:35:14.620Z" + }, + "status": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.620Z" + } + } + }, + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": null + }, + "playbackStatus": { + "value": null + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "extraSilence", + "timestamp": "2025-07-10T11:00:38.909Z" + } + }, + "ocf": { + "st": { + "value": "2024-01-01T09:00:15Z", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnfv": { + "value": "20250123.105306", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "di": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmo": { + "value": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "vid": { + "value": "DA-RVC-MAP-01011", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnpv": { + "value": "1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "pi": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerAudioClip", + "custom.hepaFilter", + "imageCapture", + "mediaPlaybackRepeat", + "mediaPlayback", + "mediaTrackControl", + "samsungce.robotCleanerPatrol", + "samsungce.musicPlaylist", + "audioVolume", + "audioMute", + "videoCapture", + "samsungce.robotCleanerWelcome", + "samsungce.microphoneSettings", + "samsungce.robotCleanerGuidedPatrol", + "samsungce.robotCleanerSafetyPatrol", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "audioTrackAddressing", + "samsungce.robotCleanerMonitoringAutomation" + ], + "timestamp": "2025-06-20T14:12:58.125Z" + } + }, + "logTrigger": { + "logState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logRequestState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logInfo": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25040102, + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "endpoint": { + "value": "PIPER", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "VR0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "tsId": { + "value": "DA10", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": null + }, + "hepaFilterResetType": { + "value": null + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerMapCleaningInfo": { + "area": { + "value": "None", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "cleanedExtent": { + "value": -1, + "unit": "m\u00b2", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "nearObject": { + "value": "None", + "timestamp": "2025-07-02T04:35:13.567Z" + }, + "remainingTime": { + "value": -1, + "unit": "minute", + "timestamp": "2025-07-10T06:42:57.820Z" + } + }, + "audioVolume": { + "volume": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 981, + "deltaEnergy": 21, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-07-10T11:11:22Z", + "end": "2025-07-10T11:20:22Z" + }, + "timestamp": "2025-07-10T11:20:22.600Z" + } + }, + "samsungce.robotCleanerMapList": { + "maps": { + "value": [ + { + "id": "1", + "name": "Map1", + "userEdited": false, + "createdTime": "2025-07-01T08:23:29Z", + "updatedTime": "2025-07-01T08:23:29Z", + "areaInfo": [ + { + "id": "1", + "name": "Room", + "userEdited": false + }, + { + "id": "2", + "name": "Room 2", + "userEdited": false + }, + { + "id": "3", + "name": "Room 3", + "userEdited": false + }, + { + "id": "4", + "name": "Room 4", + "userEdited": false + } + ], + "objectInfo": [] + } + ], + "timestamp": "2025-07-02T04:35:14.204Z" + } + }, + "samsungce.robotCleanerPatrol": { + "timezone": { + "value": null + }, + "patrolStatus": { + "value": null + }, + "areaIds": { + "value": null + }, + "timeOffset": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "samsungce.robotCleanerAudioClip": { + "enabled": { + "value": null + } + }, + "samsungce.musicPlaylist": { + "currentTrack": { + "value": null + }, + "playlist": { + "value": null + } + }, + "audioNotification": {}, + "samsungce.robotCleanerPetMonitorReport": { + "report": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": "none", + "timestamp": "2025-07-02T04:35:14.341Z" + } + }, + "samsungce.robotCleanerFeatureVisibility": { + "invisibleFeatures": { + "value": [ + "Start", + "Dock", + "SelectRoom", + "DustEmit", + "SelectSpot", + "CleaningMethod", + "MopWash", + "MopDry" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + }, + "visibleFeatures": { + "value": [ + "Stop", + "Suction", + "Repeat", + "MapMerge", + "MapDivide", + "MySchedule", + "Homecare", + "CleanReport", + "CleanHistory", + "DND", + "Sound", + "NoEntryZone", + "RenameRoom", + "ResetMap", + "Accessory", + "CleaningOption", + "ObjectEdit", + "WaterLevel", + "ClimbZone" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "1", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "25012100" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "24012200" + }, + { + "id": "4", + "swType": "Bixby", + "versionNumber": "(null)" + }, + { + "id": "5", + "swType": "Firmware", + "versionNumber": "25012200" + } + ], + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-07-09T23:00:32.385Z" + }, + "otnDUID": { + "value": "JHCDM7UU7UJWQ", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-07-02T04:35:19.823Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-07-02T04:35:19.823Z" + } + }, + "samsungce.robotCleanerReservation": { + "reservations": { + "value": [ + { + "id": "2", + "enabled": true, + "dayOfWeek": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + "startTime": "02:32", + "repeatMode": "weekly", + "cleaningMode": "auto" + } + ], + "timestamp": "2025-07-02T04:35:13.844Z" + }, + "maxNumberOfReservations": { + "value": null + } + }, + "audioMute": { + "mute": { + "value": null + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "samsungce.robotCleanerMotorFilter": { + "motorFilterResetType": { + "value": ["washable"], + "timestamp": "2025-07-02T04:35:13.496Z" + }, + "motorFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:13.496Z" + } + }, + "samsungce.robotCleanerCleaningType": { + "cleaningType": { + "value": "vacuumAndMopTogether", + "timestamp": "2025-07-09T12:44:06.437Z" + }, + "supportedCleaningTypes": { + "value": ["vacuum", "mop", "vacuumAndMopTogether", "mopAfterVacuum"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "soundDetection": { + "soundDetectionState": { + "value": null + }, + "supportedSoundTypes": { + "value": null + }, + "soundDetected": { + "value": null + } + }, + "samsungce.robotCleanerWelcome": { + "coordinates": { + "value": null + } + }, + "samsungce.robotCleanerPetMonitor": { + "areaIds": { + "value": null + }, + "originator": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "monitoringStatus": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 59, + "unit": "%", + "timestamp": "2025-07-10T11:24:13.441Z" + }, + "type": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "50029141", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "80010b0002d8411f0100000000000000", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "description": { + "value": "Jet Bot V/C", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "JETBOT_COMBO_9X00_24K", + "timestamp": "2025-07-09T23:00:26.764Z" + } + }, + "samsungce.robotCleanerSystemSoundMode": { + "soundMode": { + "value": "mute", + "timestamp": "2025-07-05T18:17:55.940Z" + }, + "supportedSoundModes": { + "value": ["mute", "beep", "voice"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-07-09T23:00:26.829Z" + } + }, + "samsungce.robotCleanerPetCleaningSchedule": { + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "mapId": { + "value": null + }, + "areaIds": { + "value": null + }, + "startTime": { + "value": null + }, + "originator": { + "value": null + }, + "obsoleted": { + "value": true, + "timestamp": "2025-07-02T04:35:14.317Z" + }, + "enabled": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.234Z" + } + }, + "samsungce.microphoneSettings": { + "mute": { + "value": null + } + }, + "samsungce.robotCleanerMapAreaInfo": { + "areaInfo": { + "value": [ + { + "id": "1", + "name": "Room" + }, + { + "id": "2", + "name": "Room 2" + }, + { + "id": "3", + "name": "Room 3" + }, + { + "id": "4", + "name": "Room 4" + } + ], + "timestamp": "2025-07-03T02:33:15.133Z" + } + }, + "samsungce.audioVolumeLevel": { + "volumeLevel": { + "value": 0, + "timestamp": "2025-07-05T18:17:55.915Z" + }, + "volumeLevelRange": { + "value": { + "minimum": 0, + "maximum": 3, + "step": 1 + }, + "timestamp": "2025-07-02T04:35:13.837Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "cleaning", + "timestamp": "2025-07-10T09:38:52.938Z" + } + }, + "samsungce.robotCleanerSafetyPatrol": { + "personDetection": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["refill-drainage-kit"], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "samsungce.robotCleanerWaterSprayLevel": { + "availableWaterSprayLevels": { + "value": null + }, + "waterSprayLevel": { + "value": "mediumLow", + "timestamp": "2025-07-10T11:00:35.545Z" + }, + "supportedWaterSprayLevels": { + "value": ["high", "mediumHigh", "medium", "mediumLow", "low"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "samsungce.robotCleanerMapMetadata": { + "cellSize": { + "value": 20, + "unit": "mm", + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.robotCleanerGuidedPatrol": { + "mapId": { + "value": null + }, + "waypoints": { + "value": null + } + }, + "audioTrackAddressing": {}, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "charging", + "charged", + "chargingForRemainingJob", + "moving", + "cleaning", + "paused", + "idle", + "error", + "powerSaving", + "factoryReset", + "relocal", + "exploring", + "processing", + "emitDust", + "washingMop", + "sterilizingMop", + "dryingMop", + "supplyingWater", + "preparingWater", + "spinDrying", + "flexCharged", + "descaling", + "drainingWater", + "waitingForDescaling" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "operatingState": { + "value": "dryingMop", + "timestamp": "2025-07-10T09:52:40.510Z" + }, + "cleaningStep": { + "value": "none", + "timestamp": "2025-07-10T09:37:07.214Z" + }, + "homingReason": { + "value": "none", + "timestamp": "2025-07-10T09:37:45.152Z" + }, + "isMapBasedOperationAvailable": { + "value": false, + "timestamp": "2025-07-10T09:37:55.690Z" + } + }, + "samsungce.soundDetectionSensitivity": { + "level": { + "value": null + }, + "supportedLevels": { + "value": null + } + }, + "samsungce.robotCleanerMonitoringAutomation": {}, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": null + } + }, + "imageCapture": { + "image": { + "value": null + }, + "encrypted": { + "value": null + }, + "captureTime": { + "value": null + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": [ + "auto", + "area", + "spot", + "stop", + "uncleanedObject", + "patternMap" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "repeatModeEnabled": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "samsungce.robotCleanerAvpRegistration": { + "registrationStatus": { + "value": null + } + }, + "samsungce.robotCleanerDrivingMode": { + "drivingMode": { + "value": "areaThenWalls", + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportedDrivingModes": { + "value": ["areaThenWalls", "wallFirst", "quickCleaningZigzagPattern"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": "off", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "startTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "endTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "on", + "timestamp": "2025-07-10T11:20:40.419Z" + }, + "supportedBrightnessLevel": { + "value": ["on", "off"], + "timestamp": "2025-06-20T14:12:57.383Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json new file mode 100644 index 00000000000..f25797f2dcf --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json @@ -0,0 +1,353 @@ +{ + "items": [ + { + "deviceId": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-MAP-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d31d0982-9bf9-4f0c-afd4-ad3d78842541", + "ownerId": "85532262-6537-54d9-179a-333db98dbcc0", + "roomId": "572f5713-53a9-4fb8-85fd-60515e44f1ed", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "imageCapture", + "version": 1 + }, + { + "id": "logTrigger", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "soundDetection", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.audioVolumeLevel", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.microphoneSettings", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.musicPlaylist", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDrivingMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningType", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapAreaInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapCleaningInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetCleaningSchedule", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitor", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitorReport", + "version": 1 + }, + { + "id": "samsungce.robotCleanerReservation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMotorFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAvpRegistration", + "version": 1 + }, + { + "id": "samsungce.soundDetectionSensitivity", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWaterSprayLevel", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWelcome", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAudioClip", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMonitoringAutomation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapMetadata", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapList", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSystemSoundMode", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.robotCleanerFeatureVisibility", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.robotCleanerGuidedPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSafetyPatrol", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "station", + "label": "station", + "capabilities": [ + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDustBag", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "refill-drainage-kit", + "label": "refill-drainage-kit", + "capabilities": [ + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.drainFilter", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.activationState", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-06-20T14:12:56.260Z", + "profile": { + "id": "5d345d41-a497-3fc7-84fe-eaaee50f0509" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "platformVersion": "1.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250123.105306", + "vendorId": "DA-RVC-MAP-01011", + "vendorResourceClientServerVersion": "4.0.38", + "lastSignupTime": "2025-06-20T14:12:56.202953160Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false, + "modelCode": "NONE" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 446eca63fb2..6ce3992d2b4 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -728,6 +728,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_rvc_map_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '05accb39-2017-c98b-a5ab-04a81f4d3d9a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'JETBOT_COMBO_9X00_24K', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250123.105306', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 7dd57e89c6a..8950846ba21 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -172,6 +172,63 @@ 'state': 'extra_high', }) # --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.robot_vacuum_lamp', + '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': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Lamp', + 'options': list([ + 'on', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.robot_vacuum_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f88524116ee..169359118da 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6066,6 +6066,540 @@ 'state': '97', }) # --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-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.robot_vacuum_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_cleaning_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_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.robot_vacuum_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.981', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-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.robot_vacuum_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-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.robot_vacuum_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_movement', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_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.robot_vacuum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Robot vacuum Power', + 'power_consumption_end': '2025-07-10T11:20:22Z', + 'power_consumption_start': '2025-07-10T11:11:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_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.robot_vacuum_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_turbo_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'extra_silence', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d0ea3dbcdad..1aaeb35205f 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -623,6 +623,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-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.robot_vacuum', + '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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d393d5fdbbc70e84cebdff81bd0a2e081315ccd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 11 Jul 2025 15:27:06 +0100 Subject: [PATCH 0103/1118] Use non-autospec mock for Reolink's util and view tests (#148579) --- tests/components/reolink/conftest.py | 2 ++ tests/components/reolink/test_util.py | 12 +++++------- tests/components/reolink/test_views.py | 23 +++++++++++------------ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d34a27045fe..1ca6bb4eb55 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -84,6 +84,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.set_whiteled = AsyncMock() host_mock.set_state_light = AsyncMock() host_mock.renew = AsyncMock() + host_mock.get_vod_source = AsyncMock() + host_mock.expire_session = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 181249b8bff..8b730bc708b 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -103,12 +103,12 @@ DEV_ID_STANDALONE_CAM = f"{TEST_UID_CAM}" async def test_try_function( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, side_effect: ReolinkError, expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" - reolink_connect.volume.return_value = 80 + reolink_host.volume.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -117,7 +117,7 @@ async def test_try_function( entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" - reolink_connect.set_volume.side_effect = side_effect + reolink_host.set_volume.side_effect = side_effect with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, @@ -128,8 +128,6 @@ async def test_try_function( assert err.value.translation_key == expected.translation_key - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.parametrize( ("identifiers"), @@ -141,12 +139,12 @@ async def test_try_function( async def test_get_device_uid_and_ch( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, device_registry: dr.DeviceRegistry, identifiers: set[tuple[str, str]], ) -> None: """Test get_device_uid_and_ch with multiple identifiers.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] dev_entry = device_registry.async_get_or_create( identifiers=identifiers, diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 992e47f0575..6da9fbd29ca 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -64,14 +64,14 @@ def get_mock_session( ) async def test_playback_proxy( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, content_type: str, ) -> None: """Test successful playback proxy URL.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session(content_type=content_type) @@ -100,12 +100,12 @@ async def test_playback_proxy( async def test_proxy_get_source_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test error while getting source for playback proxy URL.""" - reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR) + reolink_host.get_vod_source.side_effect = ReolinkError(TEST_ERROR) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -123,12 +123,11 @@ async def test_proxy_get_source_error( assert await response.content.read() == bytes(TEST_ERROR, "utf-8") assert response.status == HTTPStatus.BAD_REQUEST - reolink_connect.get_vod_source.side_effect = None async def test_proxy_invalid_config_entry_id( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: @@ -156,12 +155,12 @@ async def test_proxy_invalid_config_entry_id( async def test_playback_proxy_timeout( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a timeout in the second chunk.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session([b"test", TimeoutError()], 4) @@ -190,13 +189,13 @@ async def test_playback_proxy_timeout( @pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = get_mock_session(content_type=content_type) @@ -223,12 +222,12 @@ async def test_playback_wrong_content( async def test_playback_connect_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a connection error.""" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) mock_session = Mock() mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR)) From e0179a7d451a1bb8f31923d5ac5c525db8f9defe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 12 Jul 2025 01:53:38 +0700 Subject: [PATCH 0104/1118] Fix Google Cloud 504 Deadline Exceeded (#148589) --- homeassistant/components/google_cloud/stt.py | 2 +- homeassistant/components/google_cloud/tts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index cd5055383ea..8a548cde8bb 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): try: responses = await self._client.streaming_recognize( requests=request_generator(), - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 16519645dee..817c424d1fc 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -218,7 +218,7 @@ class BaseGoogleCloudProvider: response = await self._client.synthesize_speech( request, - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) From 2dca78efbb403e7a48363017a21d915c206b9648 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Jul 2025 20:56:50 +0200 Subject: [PATCH 0105/1118] Improve entity registry handling of device changes (#148425) --- homeassistant/helpers/device_registry.py | 21 ++++++--- homeassistant/helpers/entity_registry.py | 60 +++++++++++++++--------- tests/helpers/test_device_registry.py | 13 ++++- tests/helpers/test_entity_registry.py | 35 ++++++++------ 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bad772abaff..bc6e7c810bf 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -144,13 +144,21 @@ DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} -class _EventDeviceRegistryUpdatedData_CreateRemove(TypedDict): - """EventDeviceRegistryUpdated data for action type 'create' and 'remove'.""" +class _EventDeviceRegistryUpdatedData_Create(TypedDict): + """EventDeviceRegistryUpdated data for action type 'create'.""" - action: Literal["create", "remove"] + action: Literal["create"] device_id: str +class _EventDeviceRegistryUpdatedData_Remove(TypedDict): + """EventDeviceRegistryUpdated data for action type 'remove'.""" + + action: Literal["remove"] + device_id: str + device: DeviceEntry + + class _EventDeviceRegistryUpdatedData_Update(TypedDict): """EventDeviceRegistryUpdated data for action type 'update'.""" @@ -160,7 +168,8 @@ class _EventDeviceRegistryUpdatedData_Update(TypedDict): type EventDeviceRegistryUpdatedData = ( - _EventDeviceRegistryUpdatedData_CreateRemove + _EventDeviceRegistryUpdatedData_Create + | _EventDeviceRegistryUpdatedData_Remove | _EventDeviceRegistryUpdatedData_Update ) @@ -1309,8 +1318,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, - _EventDeviceRegistryUpdatedData_CreateRemove( - action="remove", device_id=device_id + _EventDeviceRegistryUpdatedData_Remove( + action="remove", device_id=device_id, device=device ), ) self.async_schedule_save() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0b61c3e8f16..ddb25c7b0a8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1103,8 +1103,17 @@ class EntityRegistry(BaseRegistry): entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) + removed_device = event.data["device"] for entity in entities: - self.async_remove(entity.entity_id) + config_entry_id = entity.config_entry_id + if ( + config_entry_id in removed_device.config_entries + and entity.config_subentry_id + in removed_device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) + else: + self.async_update_entity(entity.entity_id, device_id=None) return if event.data["action"] != "update": @@ -1121,29 +1130,38 @@ class EntityRegistry(BaseRegistry): # Remove entities which belong to config entries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - entity.config_entry_id is not None - and entity.config_entry_id not in device.config_entries - ): - self.async_remove(entity.entity_id) + if old_config_entries := event.data["changes"].get("config_entries"): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + if ( + entity.config_entry_id in old_config_entries + and entity.config_entry_id not in device.config_entries + ): + self.async_remove(entity.entity_id) # Remove entities which belong to config subentries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - (config_entry_id := entity.config_entry_id) is not None - and config_entry_id in device.config_entries - and entity.config_subentry_id - not in device.config_entries_subentries[config_entry_id] - ): - self.async_remove(entity.entity_id) + if old_config_entries_subentries := event.data["changes"].get( + "config_entries_subentries" + ): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + config_subentry_id = entity.config_subentry_id + if ( + config_entry_id in device.config_entries + and config_entry_id in old_config_entries_subentries + and config_subentry_id + in old_config_entries_subentries[config_entry_id] + and config_subentry_id + not in device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) # Re-enable disabled entities if the device is no longer disabled if not device.disabled: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 58933ca4314..23a451dd06c 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1652,6 +1652,7 @@ async def test_removing_config_entries( assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } @@ -1724,10 +1725,12 @@ async def test_deleted_device_removing_config_entries( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": entry2, } assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } device_registry.async_clear_config_entry(config_entry_1.entry_id) @@ -1973,6 +1976,7 @@ async def test_removing_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry.id, + "device": entry, } @@ -2102,6 +2106,7 @@ async def test_deleted_device_removing_config_subentries( assert update_events[4].data == { "action": "remove", "device_id": entry.id, + "device": entry4, } device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) @@ -2925,6 +2930,7 @@ async def test_update_remove_config_entries( assert update_events[6].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } @@ -3104,6 +3110,7 @@ async def test_update_remove_config_subentries( config_entry_3.entry_id: {None}, } + entry_before_remove = entry entry = device_registry.async_update_device( entry_id, remove_config_entry_id=config_entry_3.entry_id, @@ -3201,6 +3208,7 @@ async def test_update_remove_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry_id, + "device": entry_before_remove, } @@ -3422,7 +3430,7 @@ async def test_restore_device( ) # Apply user customizations - device_registry.async_update_device( + entry = device_registry.async_update_device( entry.id, area_id="12345A", disabled_by=dr.DeviceEntryDisabler.USER, @@ -3543,6 +3551,7 @@ async def test_restore_device( assert update_events[2].data == { "action": "remove", "device_id": entry.id, + "device": entry, } assert update_events[3].data == { "action": "create", @@ -3865,6 +3874,7 @@ async def test_restore_shared_device( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": updated_device, } assert update_events[4].data == { "action": "create", @@ -3873,6 +3883,7 @@ async def test_restore_shared_device( assert update_events[5].data == { "action": "remove", "device_id": entry.id, + "device": entry2, } assert update_events[6].data == { "action": "create", diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 5afffebb5f6..40a26295cbb 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1684,20 +1684,23 @@ async def test_remove_config_entry_from_device_removes_entities_2( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities which are not tied to the removed config entry should not be removed assert entity_registry.async_is_registered(entry_1.entity_id) - # Entities with a config entry not in the device are removed - assert not entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device + # Remove the second config entry from the device (this removes the device) device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # The device is removed, both entities are now removed - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) + # Entities which are not tied to a config entry in the device should not be removed + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None async def test_remove_config_subentry_from_device_removes_entities( @@ -1921,12 +1924,12 @@ async def test_remove_config_subentry_from_device_removes_entities_2( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) - # Entities with a config subentry not in the device are removed - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the second config subentry from the device + # Remove the second config subentry from the device, this removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -1935,10 +1938,14 @@ async def test_remove_config_subentry_from_device_removes_entities_2( await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # All entities are now removed - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + # Entities with a config subentry not in the device are not removed + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None + assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( From 1920edd71203e3e419a08e9debbd7d26d07b94bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 22:10:12 +0200 Subject: [PATCH 0106/1118] Update Google Generative AI Conversation max tokens to 3000 (#148625) Co-authored-by: Claude --- .../components/google_generative_ai_conversation/const.py | 2 +- .../snapshots/test_diagnostics.ambr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index e7c5ba6bd22..b7091fe0222 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -25,7 +25,7 @@ RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1500 +RECOMMENDED_MAX_TOKENS = 3000 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index bf44b1cbc04..d3e27eb99d2 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -21,7 +21,7 @@ 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, + 'max_tokens': 3000, 'prompt': 'Speak like a pirate', 'recommended': False, 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', From 017cd0bf4563c9b83d37d48cca7639ce8c393164 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 22:59:51 +0200 Subject: [PATCH 0107/1118] Update OpenAI conversation max tokens to 3000 (#148623) Co-authored-by: Claude --- homeassistant/components/openai_conversation/const.py | 2 +- tests/components/openai_conversation/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 777ded55657..a15f71118c0 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -28,7 +28,7 @@ CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 7af1151075c..e728d0019b6 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -381,7 +381,7 @@ async def test_generate_content_service( """Test generate content service.""" service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" - expected_args["max_output_tokens"] = 150 + expected_args["max_output_tokens"] = 3000 expected_args["top_p"] = 1.0 expected_args["temperature"] = 1.0 expected_args["user"] = None From 6ecaca753dfc28c95a458c4282d6f91828a3a9a4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 23:00:04 +0200 Subject: [PATCH 0108/1118] Update Anthropic max tokens to 3000 and recommended model to claude-3-5-haiku-latest (#148624) Co-authored-by: Claude --- homeassistant/components/anthropic/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index d7e10dd7af2..a1637a8cef6 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation" CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307" +RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1024 +RECOMMENDED_MAX_TOKENS = 3000 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_THINKING_BUDGET = "thinking_budget" From 87e641bf5952fd4afccbe31d0b77cf9f2e423ef3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 23:15:13 +0200 Subject: [PATCH 0109/1118] Update recommended model for Ollama to Qwen3 (#148627) --- homeassistant/components/ollama/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 7e80570bd5e..093e20f5140 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -158,7 +158,7 @@ MODEL_NAMES = [ # https://ollama.com/library "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.2:latest" +DEFAULT_MODEL = "qwen3:4b" DEFAULT_CONVERSATION_NAME = "Ollama Conversation" DEFAULT_AI_TASK_NAME = "Ollama AI Task" From ad881d892ba354ffa09160e2d6ab0fc18ed2574c Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 11 Jul 2025 23:45:57 +0200 Subject: [PATCH 0110/1118] Keep entities of dead Z-Wave devices available (#148611) --- homeassistant/components/zwave_js/entity.py | 22 +---------- homeassistant/components/zwave_js/update.py | 19 ++++------ tests/components/zwave_js/test_init.py | 42 ++++++++++++++++++++- tests/components/zwave_js/test_lock.py | 5 ++- tests/components/zwave_js/test_update.py | 14 +------ 5 files changed, 54 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d1ab9009308..08a587d8d20 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Sequence from typing import Any -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import ( @@ -27,8 +26,6 @@ from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id EVENT_VALUE_REMOVED = "value removed" -EVENT_DEAD = "dead" -EVENT_ALIVE = "alive" class ZWaveBaseEntity(Entity): @@ -141,11 +138,6 @@ class ZWaveBaseEntity(Entity): ) ) - for status_event in (EVENT_ALIVE, EVENT_DEAD): - self.async_on_remove( - self.info.node.on(status_event, self._node_status_alive_or_dead) - ) - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -211,19 +203,7 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return ( - self.driver.client.connected - and bool(self.info.node.ready) - and self.info.node.status != NodeStatus.DEAD - ) - - @callback - def _node_status_alive_or_dead(self, event_data: dict) -> None: - """Call when node status changes to alive or dead. - - Should not be overridden by subclasses. - """ - self.async_write_ha_state() + return self.driver.client.connected and bool(self.info.node.ready) @callback def _value_changed(self, event_data: dict) -> None: diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 4355857f5df..89fb4dd4aba 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -199,18 +199,13 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) return - # If device is asleep/dead, wait for it to wake up/become alive before - # attempting an update - for status, event_name in ( - (NodeStatus.ASLEEP, "wake up"), - (NodeStatus.DEAD, "alive"), - ): - if self.node.status == status: - if not self._status_unsub: - self._status_unsub = self.node.once( - event_name, self._update_on_status_change - ) - return + # If device is asleep, wait for it to wake up before attempting an update + if self.node.status == NodeStatus.ASLEEP: + if not self._status_unsub: + self._status_unsub = self.node.once( + "wake up", self._update_on_status_change + ) + return try: # Retrieve all firmware updates including non-stable ones but filter diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4350d7f7649..324a0f14941 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -37,7 +37,11 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY +from .common import ( + AIR_TEMPERATURE_SENSOR, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + EATON_RF9640_ENTITY, +) from tests.common import ( MockConfigEntry, @@ -2168,3 +2172,39 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_entity_available_when_node_dead( + hass: HomeAssistant, client, bulb_6_multi_color, integration +) -> None: + """Test that entities remain available even when the node is dead.""" + + node = bulb_6_multi_color + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state != STATE_UNAVAILABLE + + # Send dead event to the node + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should remain available even though the node is dead + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE + + # Send alive event to bring the node back + event = Event( + "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should still be available + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 1011026ac68..9e36810872f 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -28,7 +28,7 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -295,7 +295,8 @@ async def test_door_lock( assert node.status == NodeStatus.DEAD state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_UNAVAILABLE + # The state should still be locked, even if the node is dead + assert state.state == LockState.LOCKED async def test_only_one_lock( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fc225d529a6..17f154f4f78 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -277,7 +277,7 @@ async def test_update_entity_dead( zen_31, integration, ) -> None: - """Test update occurs when device is dead after it becomes alive.""" + """Test update occurs even when device is dead.""" event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": zen_31.node_id}, @@ -290,17 +290,7 @@ async def test_update_entity_dead( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 - - event = Event( - "alive", - data={"source": "node", "event": "alive", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the node is up we can check for updates + # Checking for firmware updates should proceed even for dead nodes assert len(client.async_send_command.call_args_list) > 0 args = client.async_send_command.call_args_list[0][0][0] From 28994152aeac51d8674360ca0f7635170ce907a2 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 12 Jul 2025 12:24:59 +0200 Subject: [PATCH 0111/1118] Wallbox - Add translation to exception (#148644) --- homeassistant/components/wallbox/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 82a807e4d09..6b0bcf4dde2 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -108,7 +108,9 @@ def _validate(wallbox: Wallbox) -> None: wallbox.authenticate() except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise ConnectionError from wallbox_connection_error From cf2ef4cec1e92e99a553a1eb62895327ae73c101 Mon Sep 17 00:00:00 2001 From: 0xEF <48224539+hexEF@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:30:26 +0200 Subject: [PATCH 0112/1118] Bump nyt_games to 0.5.0 (#148654) --- .../components/nyt_games/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/nyt_games/fixtures/latest.json | 57 ++++++++++--------- .../nyt_games/fixtures/new_account.json | 45 ++++++++------- .../nyt_games/snapshots/test_sensor.ambr | 8 +-- 6 files changed, 61 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index c32de754782..db3ad6a85f1 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.4.4"] + "requirements": ["nyt_games==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd5108a807a..49b5c63c06e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4cd94a3f6ff..d3f35ebf92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/fixtures/latest.json b/tests/components/nyt_games/fixtures/latest.json index 73a6f440fc0..16601243052 100644 --- a/tests/components/nyt_games/fixtures/latest.json +++ b/tests/components/nyt_games/fixtures/latest.json @@ -25,43 +25,46 @@ }, "wordle": { "legacyStats": { - "gamesPlayed": 70, - "gamesWon": 51, + "gamesPlayed": 1111, + "gamesWon": 1069, "guesses": { "1": 0, - "2": 1, - "3": 7, - "4": 11, - "5": 20, - "6": 12, - "fail": 19 + "2": 8, + "3": 83, + "4": 440, + "5": 372, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonDayOffset": 1189, + "currentStreak": 229, + "maxStreak": 229, + "lastWonDayOffset": 1472, "hasPlayed": true, - "autoOptInTimestamp": 1708273168957, - "hasMadeStatsChoice": false, - "timestamp": 1726831978 + "autoOptInTimestamp": 1712205417018, + "hasMadeStatsChoice": true, + "timestamp": 1751255756 }, "calculatedStats": { - "gamesPlayed": 33, - "gamesWon": 26, + "currentStreak": 237, + "maxStreak": 241, + "lastWonPrintDate": "2025-07-08", + "lastCompletedPrintDate": "2025-07-08", + "hasPlayed": true + }, + "totalStats": { + "gamesWon": 1077, + "gamesPlayed": 1119, "guesses": { "1": 0, - "2": 1, - "3": 4, - "4": 7, - "5": 10, - "6": 4, - "fail": 7 + "2": 8, + "3": 83, + "4": 444, + "5": 376, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonPrintDate": "2024-09-20", - "lastCompletedPrintDate": "2024-09-20", "hasPlayed": true, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/fixtures/new_account.json b/tests/components/nyt_games/fixtures/new_account.json index ad4d8e2e416..d35ce4cdebc 100644 --- a/tests/components/nyt_games/fixtures/new_account.json +++ b/tests/components/nyt_games/fixtures/new_account.json @@ -7,26 +7,6 @@ "stats": { "wordle": { "legacyStats": { - "gamesPlayed": 1, - "gamesWon": 1, - "guesses": { - "1": 0, - "2": 0, - "3": 0, - "4": 0, - "5": 1, - "6": 0, - "fail": 0 - }, - "currentStreak": 0, - "maxStreak": 1, - "lastWonDayOffset": 1118, - "hasPlayed": true, - "autoOptInTimestamp": 1727357874700, - "hasMadeStatsChoice": false, - "timestamp": 1727358123 - }, - "calculatedStats": { "gamesPlayed": 0, "gamesWon": 0, "guesses": { @@ -38,12 +18,35 @@ "6": 0, "fail": 0 }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonDayOffset": 1118, + "hasPlayed": true, + "autoOptInTimestamp": 1727357874700, + "hasMadeStatsChoice": false, + "timestamp": 1727358123 + }, + "calculatedStats": { "currentStreak": 0, "maxStreak": 1, "lastWonPrintDate": "", "lastCompletedPrintDate": "", + "hasPlayed": false + }, + "totalStats": { + "gamesPlayed": 1, + "gamesWon": 1, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 1, + "6": 0, + "fail": 0 + }, "hasPlayed": false, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 5a1aa384f0f..10fddcfa365 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -473,7 +473,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '237', }) # --- # name: test_all_entities[sensor.wordle_highest_streak-entry] @@ -529,7 +529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '241', }) # --- # name: test_all_entities[sensor.wordle_played-entry] @@ -581,7 +581,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70', + 'state': '1119', }) # --- # name: test_all_entities[sensor.wordle_won-entry] @@ -633,6 +633,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '51', + 'state': '1077', }) # --- From 72dc2b15d5e10550f751658f7575e24c92083b9d Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 12 Jul 2025 20:40:39 +0200 Subject: [PATCH 0113/1118] Wallbox Add translation to exception config entry auth failed (#148649) --- homeassistant/components/wallbox/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 6b0bcf4dde2..23b028330d1 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -94,7 +94,9 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( return func(self, *args, **kwargs) except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error From 531f1f196434f52e74ea2951dcf5ed5ecc3922e7 Mon Sep 17 00:00:00 2001 From: falconindy Date: Sat, 12 Jul 2025 14:46:03 -0400 Subject: [PATCH 0114/1118] snoo: use correct value for right safety clip binary sensor (#148647) --- homeassistant/components/snoo/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py index 3c91db5b86d..c4eaddcc1fe 100644 --- a/homeassistant/components/snoo/binary_sensor.py +++ b/homeassistant/components/snoo/binary_sensor.py @@ -38,7 +38,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ SnooBinarySensorEntityDescription( key="right_clip", translation_key="right_clip", - value_fn=lambda data: data.left_safety_clip, + value_fn=lambda data: data.right_safety_clip, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), From ccc1f01ff6cd30ed2c4ac6c35eba95a8d565e40e Mon Sep 17 00:00:00 2001 From: jvits227 <133175738+jvits227@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:51:09 -0400 Subject: [PATCH 0115/1118] Add lamp states to smartthings selector (#148302) Co-authored-by: Joostlek --- homeassistant/components/smartthings/select.py | 5 +++++ tests/components/smartthings/snapshots/test_select.ambr | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 99dc7a09f87..3106aba5e49 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -18,6 +18,11 @@ from .entity import SmartThingsEntity LAMP_TO_HA = { "extraHigh": "extra_high", + "high": "high", + "mid": "mid", + "low": "low", + "on": "on", + "off": "off", } WASHER_SOIL_LEVEL_TO_HA = { diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 8950846ba21..d36132cc1ef 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] @@ -112,7 +112,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'high', }) # --- # name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] @@ -226,7 +226,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] From 5287f4de812cd24fd61b2550e8853c04dbd04e2a Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Sat, 12 Jul 2025 23:52:26 +0300 Subject: [PATCH 0116/1118] Bump pyatv to 0.16.1 (#148659) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index b10a14af32b..fe500d2bfb0 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.16.0"], + "requirements": ["pyatv==0.16.1"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 49b5c63c06e..23ac894f93f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1848,7 +1848,7 @@ pyatag==0.3.5.3 pyatmo==9.2.1 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3f35ebf92d..2be2e935cd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1553,7 +1553,7 @@ pyatag==0.3.5.3 pyatmo==9.2.1 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 From fca6dc264f388554b59db4d31d9a87d1e4c27b4e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 01:11:37 +0200 Subject: [PATCH 0117/1118] Update bleak to 1.0.1 (#147742) Co-authored-by: J. Nick Koston --- .../components/bluetooth/manifest.json | 8 ++-- .../components/eq3btsmart/manifest.json | 2 +- .../components/esphome/manifest.json | 2 +- .../components/keymitt_ble/__init__.py | 32 ++++++++++++- homeassistant/package_constraints.txt | 8 ++-- requirements_all.txt | 10 ++-- requirements_test_all.txt | 10 ++-- script/hassfest/requirements.py | 19 ++------ tests/components/bluetooth/__init__.py | 48 +++++++++---------- tests/components/bluetooth/test_api.py | 2 - .../components/bluetooth/test_base_scanner.py | 9 ---- .../components/bluetooth/test_diagnostics.py | 2 +- tests/components/bluetooth/test_manager.py | 29 ++++------- tests/components/bluetooth/test_models.py | 17 ++----- tests/components/bluetooth/test_usage.py | 2 - .../bluetooth/test_websocket_api.py | 14 ++---- tests/components/bluetooth/test_wrappers.py | 47 +++++++++--------- .../esphome/bluetooth/test_client.py | 2 +- 18 files changed, 121 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 33914f3457f..cf3ee8e0db9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,12 +15,12 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.22.3", - "bleak-retry-connector==3.9.0", - "bluetooth-adapters==0.21.4", + "bleak==1.0.1", + "bleak-retry-connector==4.0.0", + "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.43.0", - "habluetooth==3.49.0" + "habluetooth==4.0.1" ] } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 62128077f2f..472384fdf7d 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==2.1.0", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9099af63ad9..e094fd5daa7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==34.2.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==2.16.0" + "bleak-esphome==3.1.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 01948006852..0f71519e420 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,14 +2,42 @@ from __future__ import annotations -from microbot import MicroBotApiClient +from collections.abc import Generator +from contextlib import contextmanager + +import bleak from homeassistant.components import bluetooth from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator + +@contextmanager +def patch_unused_bleak_discover_import() -> Generator[None]: + """Patch bleak.discover import in microbot. It is unused and was removed in bleak 1.0.0.""" + + def getattr_bleak(name: str) -> object: + if name == "discover": + return None + raise AttributeError + + original_func = bleak.__dict__.get("__getattr__") + bleak.__dict__["__getattr__"] = getattr_bleak + try: + yield + finally: + if original_func is not None: + bleak.__dict__["__getattr__"] = original_func + + +with patch_unused_bleak_discover_import(): + from microbot import MicroBotApiClient + +from .coordinator import ( # noqa: E402 + MicroBotConfigEntry, + MicroBotDataUpdateCoordinator, +) PLATFORMS: list[str] = [Platform.SWITCH] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 89ff2238f61..9e21c5830e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,9 +20,9 @@ audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 bcrypt==4.3.0 -bleak-retry-connector==3.9.0 -bleak==0.22.3 -bluetooth-adapters==0.21.4 +bleak-retry-connector==4.0.0 +bleak==1.0.1 +bluetooth-adapters==2.0.0 bluetooth-auto-recovery==1.5.2 bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 @@ -34,7 +34,7 @@ dbus-fast==2.43.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==3.49.0 +habluetooth==4.0.1 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 23ac894f93f..13742320bf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -616,13 +616,13 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.0 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -643,7 +643,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -1124,7 +1124,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==4.0.1 # homeassistant.components.cloud hass-nabucasa==0.106.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2be2e935cd7..098c474d2f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,13 +550,13 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.0 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -574,7 +574,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -985,7 +985,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==4.0.1 # homeassistant.components.cloud hass-nabucasa==0.106.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index d7d064fff28..b334b75451e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -27,6 +27,7 @@ PACKAGE_CHECK_VERSION_RANGE = { "aiohttp": "SemVer", "attrs": "CalVer", "awesomeversion": "CalVer", + "bleak": "SemVer", "grpcio": "SemVer", "httpx": "SemVer", "mashumaro": "SemVer", @@ -297,10 +298,6 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # - domain is the integration domain # - package is the package (can be transitive) referencing the dependency # - dependencyX should be the name of the referenced dependency - "bluetooth": { - # https://github.com/hbldh/bleak/pull/1718 (not yet released) - "homeassistant": {"bleak"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} @@ -501,17 +498,9 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: continue # Check for restrictive version limits on Python - if ( - (requires_python := metadata(package)["Requires-Python"]) - and not all( - _is_dependency_version_range_valid(version_part, "SemVer") - for version_part in requires_python.split(",") - ) - # "bleak" is a transient dependency of 53 integrations, and we don't - # want to add the whole list to PYTHON_VERSION_CHECK_EXCEPTIONS - # This extra check can be removed when bleak is updated - # https://github.com/hbldh/bleak/pull/1718 - and (package in packages or package != "bleak") + if (requires_python := metadata(package)["Requires-Python"]) and not all( + _is_dependency_version_range_valid(version_part, "SemVer") + for version_part in requires_python.split(",") ): needs_python_version_check_exception = True integration.add_warning_or_error( diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 31d301e2dac..d439f46bb71 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,11 +1,11 @@ """Tests for the Bluetooth integration.""" -from collections.abc import Iterable +from collections.abc import Generator, Iterable from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -53,7 +53,6 @@ ADVERTISEMENT_DATA_DEFAULTS = { BLE_DEVICE_DEFAULTS = { "name": None, - "rssi": -127, "details": None, } @@ -89,7 +88,6 @@ def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, - rssi: int | None = None, **kwargs: Any, ) -> BLEDevice: """Generate a BLEDevice with defaults.""" @@ -100,8 +98,6 @@ def generate_ble_device( new["name"] = name if details is not None: new["details"] = details - if rssi is not None: - new["rssi"] = rssi for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) return BLEDevice(**new) @@ -215,34 +211,35 @@ def inject_bluetooth_service_info( @contextmanager -def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock all the discovered devices from all the scanners.""" manager = _get_manager() - original_history = {} scanners = list( itertools.chain( manager._connectable_scanners, manager._non_connectable_scanners ) ) - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - original_history[scanner] = data.copy() - data.clear() - if scanners: - data = scanners[0].discovered_devices_and_advertisement_data - data.clear() - data.update( - {device.address: (device, MagicMock()) for device in mock_discovered} - ) - yield - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - data.clear() - data.update(original_history[scanner]) + if scanners and getattr(scanners[0], "scanner", None): + with patch.object( + scanners[0].scanner.__class__, + "discovered_devices_and_advertisement_data", + new=PropertyMock( + side_effect=[ + { + device.address: (device, MagicMock()) + for device in mock_discovered + }, + ] + + [{}] * (len(scanners)) + ), + ): + yield + else: + yield @contextmanager -def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock the combined best path to discovered devices from all the scanners.""" manager = _get_manager() original_all_history = manager._all_history @@ -305,6 +302,9 @@ class MockBleakClient(BleakClient): """Mock clear_cache.""" return True + def set_disconnected_callback(self, callback, **kwargs): + """Mock set_disconnected_callback.""" + class FakeScannerMixin: def get_discovered_device_advertisement_data( diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 1468367fd9a..74373da6865 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -82,7 +82,6 @@ async def test_async_scanner_devices_by_address_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -116,7 +115,6 @@ async def test_async_scanner_devices_by_address_non_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 25dc1b9738d..f2aa3d87778 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -54,7 +54,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -67,7 +66,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", name_2, {}, - rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( local_name=name_2, @@ -80,7 +78,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohandlonger", {}, - rssi=-100, ) switchbot_device_adv_3 = generate_advertisement_data( local_name="wohandlonger", @@ -146,7 +143,6 @@ async def test_remote_scanner_expires_connectable(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -199,7 +195,6 @@ async def test_remote_scanner_expires_non_connectable(hass: HomeAssistant) -> No "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -272,7 +267,6 @@ async def test_base_scanner_connecting_behavior(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -376,7 +370,6 @@ async def test_device_with_ten_minute_advertising_interval(hass: HomeAssistant) "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -501,7 +494,6 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -545,7 +537,6 @@ async def test_remote_scanner_bluetooth_config_entry( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 540bf1bfbd1..5c4d8bda70d 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -37,7 +37,7 @@ class FakeHaScanner(FakeScannerMixin, HaScanner): """Return the discovered devices and advertisement data.""" return { "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_ble_device(name="x", address="44:44:33:11:23:45"), generate_advertisement_data(local_name="x"), ) } diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7488aa6e33c..f34afba01ef 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -78,11 +78,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -93,11 +91,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_100 ) - switchbot_device_signal_99 = generate_ble_device( - address, "wohand_signal_99", rssi=-99 - ) + switchbot_device_signal_99 = generate_ble_device(address, "wohand_signal_99") switchbot_adv_signal_99 = generate_advertisement_data( - local_name="wohand_signal_99", service_uuids=[] + local_name="wohand_signal_99", service_uuids=[], rssi=-99 ) inject_advertisement_with_source( hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS @@ -108,11 +104,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_99 ) - switchbot_device_signal_98 = generate_ble_device( - address, "wohand_good_signal", rssi=-98 - ) + switchbot_device_signal_98 = generate_ble_device(address, "wohand_good_signal") switchbot_adv_signal_98 = generate_advertisement_data( - local_name="wohand_good_signal", service_uuids=[] + local_name="wohand_good_signal", service_uuids=[], rssi=-98 ) inject_advertisement_with_source( hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS @@ -805,13 +799,11 @@ async def test_goes_unavailable_connectable_only_and_recovers( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -978,7 +970,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1394,7 +1385,6 @@ async def test_bluetooth_rediscover( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1571,7 +1561,6 @@ async def test_bluetooth_rediscover_no_match( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1693,11 +1682,9 @@ async def test_async_register_disappeared_callback( """Test bluetooth async_register_disappeared_callback handles failures.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d36741b4d5d..af367dec187 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -124,7 +124,7 @@ async def test_wrapped_bleak_client_local_adapter_only(hass: HomeAssistant) -> N "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -145,7 +145,6 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -215,7 +214,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -236,10 +235,9 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) inject_advertisement_with_source( @@ -275,10 +273,9 @@ async def test_ble_device_with_proxy_client_out_of_connections( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -340,10 +337,9 @@ async def test_ble_device_with_proxy_clear_cache(hass: HomeAssistant) -> None: "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -417,7 +413,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -511,7 +506,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_no_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( local_name="wohand", @@ -538,7 +532,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index d5d4e7ad9d0..9c3c8c6cebb 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -17,9 +17,7 @@ from . import generate_ble_device MOCK_BLE_DEVICE = generate_ble_device( "00:00:00:00:00:00", "any", - delegate="", details={"path": "/dev/hci0/device"}, - rssi=-127, ) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index 57199d04078..2e613932f3c 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -38,11 +38,9 @@ async def test_subscribe_advertisements( """Test bluetooth subscribe_advertisements.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -68,7 +66,7 @@ async def test_subscribe_advertisements( "connectable": True, "manufacturer_data": {}, "name": "wohand_signal_100", - "rssi": -127, + "rssi": -100, "service_data": {}, "service_uuids": [], "source": HCI0_SOURCE_ADDRESS, @@ -134,11 +132,9 @@ async def test_subscribe_connection_allocations( """Test bluetooth subscribe_connection_allocations.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index bfe7445f614..413c96535a6 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -92,17 +92,13 @@ class FakeBleakClient(BaseFakeBleakClient): async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True -class FakeBleakClientFailsToConnect(BaseFakeBleakClient): - """Fake bleak client that fails to connect.""" - - async def connect(self, *args, **kwargs): - """Connect.""" - return False - - class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Fake bleak client that raises on connect.""" @@ -110,6 +106,11 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Connect.""" raise ConnectionError("Test exception") + @property + def is_connected(self): + """Not connected.""" + return False + def _generate_ble_device_and_adv_data( interface: str, mac: str, rssi: int @@ -119,7 +120,6 @@ def _generate_ble_device_and_adv_data( generate_ble_device( mac, "any", - delegate="", details={"path": f"/org/bluez/{interface}/dev_{mac}"}, ), generate_advertisement_data(rssi=rssi), @@ -144,16 +144,6 @@ def mock_platform_client_fixture(): yield -@pytest.fixture(name="mock_platform_client_that_fails_to_connect") -def mock_platform_client_that_fails_to_connect_fixture(): - """Fixture that mocks the platform client that fails to connect.""" - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsToConnect, - ): - yield - - @pytest.fixture(name="mock_platform_client_that_raises_on_connect") def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" @@ -219,7 +209,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 0 @@ -251,7 +242,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:03"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert release_slot_mock.call_count == 0 cancel_hci0() @@ -262,7 +254,7 @@ async def test_test_switch_adapters_when_out_of_slots( async def test_release_slot_on_connect_failure( hass: HomeAssistant, install_bleak_catcher, - mock_platform_client_that_fails_to_connect, + mock_platform_client_that_raises_on_connect, ) -> None: """Ensure the slot gets released on connection failure.""" manager = _get_manager() @@ -278,7 +270,9 @@ async def test_release_slot_on_connect_failure( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is False + with pytest.raises(ConnectionError): + await client.connect() + assert client.is_connected is False assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 @@ -335,13 +329,18 @@ async def test_passing_subclassed_str_as_address( async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): - assert await client.connect() is True + await client.connect() + assert client.is_connected is True cancel_hci0() cancel_hci1() diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 554f1725f4b..86db1fc3109 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -55,4 +55,4 @@ async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) with pytest.raises( BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" ): - assert await client.write_gatt_char("test", b"test") is False + assert await client.write_gatt_char("test", b"test", False) is False From d33f73fce2e3abc5af50d692446a025424cb9cac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 04:26:31 +0200 Subject: [PATCH 0118/1118] Cleanup bleak warnings (#148665) --- tests/components/bluemaestro/__init__.py | 1 - tests/components/eq3btsmart/conftest.py | 2 +- tests/components/homekit_controller/conftest.py | 4 +--- tests/components/inkbird/__init__.py | 1 - tests/components/iron_os/conftest.py | 2 +- tests/components/kulersky/test_light.py | 4 +--- tests/components/leaone/__init__.py | 1 - tests/components/sensorpro/__init__.py | 1 - tests/components/sensorpush/__init__.py | 1 - tests/components/thermobeacon/__init__.py | 1 - tests/components/thermopro/__init__.py | 1 - 11 files changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/components/bluemaestro/__init__.py b/tests/components/bluemaestro/__init__.py index e598eb34597..259457453b1 100644 --- a/tests/components/bluemaestro/__init__.py +++ b/tests/components/bluemaestro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/eq3btsmart/conftest.py b/tests/components/eq3btsmart/conftest.py index 92f1be29b70..ce55a1fccbd 100644 --- a/tests/components/eq3btsmart/conftest.py +++ b/tests/components/eq3btsmart/conftest.py @@ -28,7 +28,7 @@ def fake_service_info(): source="local", connectable=False, time=0, - device=generate_ble_device(address=MAC, name="CC-RT-BLE", rssi=0), + device=generate_ble_device(address=MAC, name="CC-RT-BLE"), advertisement=AdvertisementData( local_name="CC-RT-BLE", manufacturer_data={}, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 882d0d60e66..bf05efada72 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -66,9 +66,7 @@ def fake_ble_discovery() -> Generator[None]: """Fake BLE discovery.""" class FakeBLEDiscovery(FakeDiscovery): - device = BLEDevice( - address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() - ) + device = BLEDevice(address="AA:BB:CC:DD:EE:FF", name="TestDevice", details=()) with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): yield diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 7228f64448b..1daadc9ffe8 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -29,7 +29,6 @@ def _make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=MONOTONIC_TIME(), advertisement=None, diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 479ee2fde7b..60abf8a8008 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -131,7 +131,7 @@ def mock_ble_device() -> Generator[MagicMock]: with patch( "homeassistant.components.bluetooth.async_ble_device_from_address", return_value=BLEDevice( - address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, rssi=-50, details={} + address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, details={} ), ) as ble_device: yield ble_device diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index bde60579af7..9521f98f523 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -40,9 +40,7 @@ def mock_ble_device() -> Generator[MagicMock]: """Mock BLEDevice.""" with patch( "homeassistant.components.kulersky.async_ble_device_from_address", - return_value=BLEDevice( - address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} - ), + return_value=BLEDevice(address="AA:BB:CC:11:22:33", name="Bedroom", details={}), ) as ble_device: yield ble_device diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py index befc0a81028..900fe100940 100644 --- a/tests/components/leaone/__init__.py +++ b/tests/components/leaone/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sensorpro/__init__.py b/tests/components/sensorpro/__init__.py index a63bdbe08dc..7f2a7b1f33e 100644 --- a/tests/components/sensorpro/__init__.py +++ b/tests/components/sensorpro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index 88fb2072961..6f1f80d777e 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/thermobeacon/__init__.py b/tests/components/thermobeacon/__init__.py index 32b6d823ec2..9b43e3b33f2 100644 --- a/tests/components/thermobeacon/__init__.py +++ b/tests/components/thermobeacon/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index 7ac593e6336..6971d72c460 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, From ab6ac94af9f67e44e536ecb311d485ecb2e4ec50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jul 2025 18:49:59 -1000 Subject: [PATCH 0119/1118] Bump aioesphomeapi to 35.0.0 (#148666) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e094fd5daa7..c88fa7246fe 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==34.2.0", + "aioesphomeapi==35.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 13742320bf3..fe66f48a42a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==34.2.0 +aioesphomeapi==35.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 098c474d2f9..3f0e3b62646 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==34.2.0 +aioesphomeapi==35.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 1c35aff51061b6d60ac4d45b0ca058f8fe9e77da Mon Sep 17 00:00:00 2001 From: asafhas <121308170+asafhas@users.noreply.github.com> Date: Sun, 13 Jul 2025 08:55:37 +0300 Subject: [PATCH 0120/1118] Add configuration entities to Tuya multifunction alarm (#148556) --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/icons.json | 6 + homeassistant/components/tuya/strings.json | 6 + homeassistant/components/tuya/switch.py | 16 ++ tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/mal_alarm_host.json | 225 ++++++++++++++++++ .../snapshots/test_alarm_control_panel.ambr | 53 +++++ .../tuya/snapshots/test_switch.ambr | 96 ++++++++ .../tuya/test_alarm_control_panel.py | 57 +++++ 9 files changed, 466 insertions(+) create mode 100644 tests/components/tuya/fixtures/mal_alarm_host.json create mode 100644 tests/components/tuya/snapshots/test_alarm_control_panel.ambr create mode 100644 tests/components/tuya/test_alarm_control_panel.py diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index abf5223175c..f9377114e47 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -315,6 +315,8 @@ class DPCode(StrEnum): SWITCH_6 = "switch_6" # Switch 6 SWITCH_7 = "switch_7" # Switch 7 SWITCH_8 = "switch_8" # Switch 8 + SWITCH_ALARM_LIGHT = "switch_alarm_light" + SWITCH_ALARM_SOUND = "switch_alarm_sound" SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch SWITCH_CHARGE = "switch_charge" SWITCH_CONTROLLER = "switch_controller" diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index e28371f2b3d..40bbf41fd0d 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -370,6 +370,12 @@ }, "sterilization": { "default": "mdi:minus-circle-outline" + }, + "arm_beep": { + "default": "mdi:volume-high" + }, + "siren": { + "default": "mdi:alarm-light" } } } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 5964be5ce34..a5302b2e88b 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -906,6 +906,12 @@ }, "sterilization": { "name": "Sterilization" + }, + "arm_beep": { + "name": "Arm beep" + }, + "siren": { + "name": "Siren" } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 9b4cc332d94..f455424c2c1 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -431,6 +431,22 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_SOUND, + # This switch is called "Arm Beep" in the official Tuya app + translation_key="arm_beep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_LIGHT, + # This switch is called "Siren" in the official Tuya app + translation_key="siren", + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 90a49fc2372..80e21e84c2e 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -60,6 +60,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "mal_alarm_host": [ + # Alarm Host support + Platform.ALARM_CONTROL_PANEL, + Platform.SWITCH, + ], "mcs_door_sensor": [ # https://github.com/home-assistant/core/issues/108301 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/mal_alarm_host.json b/tests/components/tuya/fixtures/mal_alarm_host.json new file mode 100644 index 00000000000..1a25a84ec2c --- /dev/null +++ b/tests/components/tuya/fixtures/mal_alarm_host.json @@ -0,0 +1,225 @@ +{ + "id": "123123aba12312312dazub", + "name": "Multifunction alarm", + "category": "mal", + "product_id": "gyitctrjj1kefxp2", + "product_name": "Multifunction alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-12-02T20:08:56+00:00", + "create_time": "2024-12-02T20:08:56+00:00", + "update_time": "2024-12-02T20:08:56+00:00", + "function": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "telnet_state": { + "type": "Enum", + "value": { + "range": [ + "normal", + "network_no", + "phone_no", + "sim_card_no", + "network_search", + "signal_level_1", + "signal_level_2", + "signal_level_3", + "signal_level_4", + "signal_level_5" + ] + } + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "alarm_msg": { + "type": "Raw", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + }, + "sub_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm", "fault", "others"] + } + } + }, + "status": { + "master_mode": "disarmed", + "delay_set": 15, + "alarm_time": 3, + "switch_alarm_sound": true, + "switch_alarm_light": true, + "switch_mode_sound": true, + "switch_kb_sound": false, + "switch_kb_light": false, + "telnet_state": "sim_card_no", + "muffling": false, + "alarm_msg": "AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5AAoAWgBvAG4AZQA6ADAAMAA1AEUAbgB0AHIAYQBuAGMAZQ==", + "switch_alarm_propel": true, + "alarm_delay_time": 20, + "master_state": "normal", + "sub_class": "remote_controller", + "sub_admin": "AgEFCggC////HABLAGkAdABjAGgAZQBuACAAUwBtAG8AawBlACBjAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADFkAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADJlAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADNmAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADQ=", + "sub_state": "normal" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..97076d5e467 --- /dev/null +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_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': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.multifunction_alarm', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.123123aba12312312dazubmaster_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Multifunction alarm', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.multifunction_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 77943ccdd29..bf970a6ffbb 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -579,6 +579,102 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-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': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + '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': 'Arm beep', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_beep', + 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Arm beep', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-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': , + 'entity_id': 'switch.multifunction_alarm_siren', + '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': 'Siren', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Siren', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py new file mode 100644 index 00000000000..71527bd83eb --- /dev/null +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -0,0 +1,57 @@ +"""Test Tuya Alarm Control Panel platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 87fd45d4ab44555a09f483a7af9a91fc68835d5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jul 2025 20:12:14 -1000 Subject: [PATCH 0121/1118] Add device_id parameter to ESPHome command calls for sub-device support (#148667) --- .../components/esphome/alarm_control_panel.py | 35 ++++-- homeassistant/components/esphome/button.py | 2 +- homeassistant/components/esphome/climate.py | 20 ++-- homeassistant/components/esphome/cover.py | 32 ++++-- homeassistant/components/esphome/date.py | 8 +- homeassistant/components/esphome/datetime.py | 4 +- homeassistant/components/esphome/fan.py | 22 +++- homeassistant/components/esphome/light.py | 4 +- homeassistant/components/esphome/lock.py | 12 ++- .../components/esphome/media_player.py | 28 ++++- homeassistant/components/esphome/number.py | 4 +- homeassistant/components/esphome/select.py | 4 +- homeassistant/components/esphome/switch.py | 8 +- homeassistant/components/esphome/text.py | 4 +- homeassistant/components/esphome/time.py | 8 +- homeassistant/components/esphome/update.py | 12 ++- homeassistant/components/esphome/valve.py | 18 +++- .../esphome/test_alarm_control_panel.py | 16 +-- tests/components/esphome/test_button.py | 2 +- tests/components/esphome/test_climate.py | 25 +++-- tests/components/esphome/test_cover.py | 14 +-- tests/components/esphome/test_date.py | 2 +- tests/components/esphome/test_datetime.py | 2 +- tests/components/esphome/test_fan.py | 56 ++++++---- tests/components/esphome/test_light.py | 84 ++++++++++++--- tests/components/esphome/test_lock.py | 10 +- tests/components/esphome/test_media_player.py | 32 ++++-- tests/components/esphome/test_number.py | 2 +- tests/components/esphome/test_select.py | 2 +- tests/components/esphome/test_switch.py | 101 +++++++++++++++++- tests/components/esphome/test_text.py | 2 +- tests/components/esphome/test_time.py | 2 +- tests/components/esphome/test_update.py | 4 +- tests/components/esphome/test_valve.py | 12 +-- 34 files changed, 458 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index ad455e620bb..70756c31f0f 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.DISARM, code + self._key, + AlarmControlPanelCommand.DISARM, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_HOME, code + self._key, + AlarmControlPanelCommand.ARM_HOME, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_AWAY, code + self._key, + AlarmControlPanelCommand.ARM_AWAY, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_NIGHT, code + self._key, + AlarmControlPanelCommand.ARM_NIGHT, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + self._key, + AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_VACATION, code + self._key, + AlarmControlPanelCommand.ARM_VACATION, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.TRIGGER, code + self._key, + AlarmControlPanelCommand.TRIGGER, + code, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 31121d98ff7..795a4bc4ed8 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): @convert_api_error_ha_error async def async_press(self) -> None: """Press the button.""" - self._client.button_command(self._key) + self._client.button_command(self._key, device_id=self._static_info.device_id) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 667d5d00154..927ea87e0bf 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] if ATTR_TARGET_TEMP_HIGH in kwargs: data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] - self._client.climate_command(**data) + self._client.climate_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - self._client.climate_command(key=self._key, target_humidity=humidity) + self._client.climate_command( + key=self._key, + target_humidity=humidity, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" self._client.climate_command( - key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) + key=self._key, + mode=_CLIMATE_MODES.from_hass(hvac_mode), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error @@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_preset"] = preset_mode else: kwargs["preset"] = _PRESETS.from_hass(preset_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -319,13 +325,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_fan_mode"] = fan_mode else: kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" self._client.climate_command( - key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) + key=self._key, + swing_mode=_SWING_MODES.from_hass(swing_mode), + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4426724e3f4..f9ff944809a 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @convert_api_error_ha_error async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._client.cover_command(key=self._key, position=1.0) + self._client.cover_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._client.cover_command(key=self._key, position=0.0) + self._client.cover_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._client.cover_command(key=self._key, stop=True) + self._client.cover_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100) + self._client.cover_command( + key=self._key, + position=kwargs[ATTR_POSITION] / 100, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - self._client.cover_command(key=self._key, tilt=1.0) + self._client.cover_command( + key=self._key, tilt=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - self._client.cover_command(key=self._key, tilt=0.0) + self._client.cover_command( + key=self._key, tilt=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - self._client.cover_command(key=self._key, tilt=tilt_position / 100) + self._client.cover_command( + key=self._key, + tilt=tilt_position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index ef446cceac6..fc125067553 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity): async def async_set_value(self, value: date) -> None: """Update the current date.""" - self._client.date_command(self._key, value.year, value.month, value.day) + self._client.date_command( + self._key, + value.year, + value.month, + value.day, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 3ea285fa849..46c5c2da2d8 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity async def async_set_value(self, value: datetime) -> None: """Update the current datetime.""" - self._client.datetime_command(self._key, int(value.timestamp())) + self._client.datetime_command( + self._key, int(value.timestamp()), device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index a4d840845a6..882cf3606e2 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): ORDERED_NAMED_FAN_SPEEDS, percentage ) data["speed"] = named_speed - self._client.fan_command(**data) + self._client.fan_command(**data, device_id=self._static_info.device_id) async def async_turn_on( self, @@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - self._client.fan_command(key=self._key, state=False) + self._client.fan_command( + key=self._key, state=False, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - self._client.fan_command(key=self._key, oscillating=oscillating) + self._client.fan_command( + key=self._key, + oscillating=oscillating, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" self._client.fan_command( - key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) + key=self._key, + direction=_FAN_DIRECTIONS.from_hass(direction), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - self._client.fan_command(key=self._key, preset_mode=preset_mode) + self._client.fan_command( + key=self._key, + preset_mode=preset_mode, + device_id=self._static_info.device_id, + ) @property @esphome_state_property diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 3e278b5b2d6..67b8e755c87 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # (fewest capabilities set) data["color_mode"] = _least_complex_color_mode(color_modes) - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: @@ -290,7 +290,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: data["transition_length"] = kwargs[ATTR_TRANSITION] - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @property @esphome_state_property diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index cfb9af614dd..d7e65470499 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): @convert_api_error_ha_error async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - self._client.lock_command(self._key, LockCommand.LOCK) + self._client.lock_command( + self._key, LockCommand.LOCK, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE) - self._client.lock_command(self._key, LockCommand.UNLOCK, code) + self._client.lock_command( + self._key, LockCommand.UNLOCK, code, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._client.lock_command(self._key, LockCommand.OPEN) + self._client.lock_command( + self._key, LockCommand.OPEN, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f18b5e7bf5c..2d43d40bfb3 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -132,7 +132,10 @@ class EsphomeMediaPlayer( media_id = proxy_url self._client.media_player_command( - self._key, media_url=media_id, announcement=announcement + self._key, + media_url=media_id, + announcement=announcement, + device_id=self._static_info.device_id, ) async def async_will_remove_from_hass(self) -> None: @@ -214,22 +217,36 @@ class EsphomeMediaPlayer( @convert_api_error_ha_error async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._client.media_player_command(self._key, volume=volume) + self._client.media_player_command( + self._key, volume=volume, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_media_pause(self) -> None: """Send pause command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PAUSE, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_play(self) -> None: """Send play command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PLAY, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_stop(self) -> None: """Send stop command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.STOP, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_mute_volume(self, mute: bool) -> None: @@ -237,6 +254,7 @@ class EsphomeMediaPlayer( self._client.media_player_command( self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 4a6800e1041..59788eb6e1f 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): @convert_api_error_ha_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - self._client.number_command(self._key, value) + self._client.number_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index d5451f69f0f..3834e4251ea 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): @convert_api_error_ha_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._client.select_command(self._key, option) + self._client.select_command( + self._key, option, device_id=self._static_info.device_id + ) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 35edbf678ad..7e5223ae548 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): @convert_api_error_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self._client.switch_command(self._key, True) + self._client.switch_command( + self._key, True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._client.switch_command(self._key, False) + self._client.switch_command( + self._key, False, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index c36621b8f4e..5ffc07ce08d 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): @convert_api_error_ha_error async def async_set_value(self, value: str) -> None: """Update the current value.""" - self._client.text_command(self._key, value) + self._client.text_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index b0e586e1792..a416bb17a31 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity): async def async_set_value(self, value: time) -> None: """Update the current time.""" - self._client.time_command(self._key, value.hour, value.minute, value.second) + self._client.time_command( + self._key, + value.hour, + value.minute, + value.second, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index cc886f2ba4c..a6d053e1c4c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): async def async_update(self) -> None: """Command device to check for update.""" if self.available: - self._client.update_command(key=self._key, command=UpdateCommand.CHECK) + self._client.update_command( + key=self._key, + command=UpdateCommand.CHECK, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Command device to install update.""" - self._client.update_command(key=self._key, command=UpdateCommand.INSTALL) + self._client.update_command( + key=self._key, + command=UpdateCommand.INSTALL, + device_id=self._static_info.device_id, + ) diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index f71a253c1f1..0fe9151a5a6 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): @convert_api_error_ha_error async def async_open_valve(self, **kwargs: Any) -> None: """Open the valve.""" - self._client.valve_command(key=self._key, position=1.0) + self._client.valve_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_valve(self, **kwargs: Any) -> None: """Close valve.""" - self._client.valve_command(key=self._key, position=0.0) + self._client.valve_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_valve(self, **kwargs: Any) -> None: """Stop the valve.""" - self._client.valve_command(key=self._key, stop=True) + self._client.valve_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_valve_position(self, position: float) -> None: """Move the valve to a specific position.""" - self._client.valve_command(key=self._key, position=position / 100) + self._client.valve_command( + key=self._key, + position=position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 62924404458..e06b88432a9 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -73,7 +73,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234")] + [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -87,7 +87,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234")] + [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -101,7 +101,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_HOME, "1234")] + [call(1, AlarmControlPanelCommand.ARM_HOME, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -115,7 +115,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234")] + [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -129,7 +129,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234")] + [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -143,7 +143,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.TRIGGER, "1234")] + [call(1, AlarmControlPanelCommand.TRIGGER, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -157,7 +157,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, "1234")] + [call(1, AlarmControlPanelCommand.DISARM, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -203,7 +203,7 @@ async def test_generic_alarm_control_panel_no_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, None)] + [call(1, AlarmControlPanelCommand.DISARM, None, device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index d3fec2a56d2..3cedc3526d4 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -39,7 +39,7 @@ async def test_button_generic_entity( {ATTR_ENTITY_ID: "button.test_my_button"}, blocking=True, ) - mock_client.button_command.assert_has_calls([call(1)]) + mock_client.button_command.assert_has_calls([call(1, device_id=0)]) state = hass.states.get("button.test_my_button") assert state is not None assert state.state != STATE_UNKNOWN diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 3c529adf21f..5c907eef3b1 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -93,7 +93,9 @@ async def test_climate_entity( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_temperature=25.0, device_id=0)] + ) mock_client.climate_command.reset_mock() @@ -167,6 +169,7 @@ async def test_climate_entity_with_step_and_two_point( mode=ClimateMode.AUTO, target_temperature_low=20.0, target_temperature_high=30.0, + device_id=0, ) ] ) @@ -232,7 +235,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0)] + [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -263,6 +266,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, mode=ClimateMode.HEAT, + device_id=0, ) ] ) @@ -279,6 +283,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, preset=ClimatePreset.AWAY, + device_id=0, ) ] ) @@ -290,7 +295,9 @@ async def test_climate_entity_with_step_and_target_temp( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) + mock_client.climate_command.assert_has_calls( + [call(key=1, custom_preset="preset1", device_id=0)] + ) mock_client.climate_command.reset_mock() await hass.services.async_call( @@ -300,7 +307,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, fan_mode=ClimateFanMode.HIGH)] + [call(key=1, fan_mode=ClimateFanMode.HIGH, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -310,7 +317,9 @@ async def test_climate_entity_with_step_and_target_temp( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) + mock_client.climate_command.assert_has_calls( + [call(key=1, custom_fan_mode="fan2", device_id=0)] + ) mock_client.climate_command.reset_mock() await hass.services.async_call( @@ -320,7 +329,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, swing_mode=ClimateSwingMode.BOTH)] + [call(key=1, swing_mode=ClimateSwingMode.BOTH, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -383,7 +392,9 @@ async def test_climate_entity_with_humidity( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HUMIDITY: 23}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_humidity=23, device_id=0)] + ) mock_client.climate_command.reset_mock() diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index f6ec9f20d6b..93524905f6b 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -74,7 +74,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -83,7 +83,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -92,7 +92,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -101,7 +101,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.cover_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -110,7 +110,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -128,7 +128,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_TILT_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5, device_id=0)]) mock_client.cover_command.reset_mock() mock_device.set_state( diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 331c3d50bd4..387838e0b23 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -47,7 +47,7 @@ async def test_generic_date_entity( {ATTR_ENTITY_ID: "date.test_my_date", ATTR_DATE: "1999-01-01"}, blocking=True, ) - mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1)]) + mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1, device_id=0)]) mock_client.date_command.reset_mock() diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 63ca02360fd..6fcfe7ed947 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -50,7 +50,7 @@ async def test_generic_datetime_entity( }, blocking=True, ) - mock_client.datetime_command.assert_has_calls([call(1, 946689825)]) + mock_client.datetime_command.assert_has_calls([call(1, 946689825, device_id=0)]) mock_client.datetime_command.reset_mock() diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 558acb281b5..a33be1a6fca 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -77,7 +77,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -88,7 +88,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.MEDIUM, state=True)] + [call(key=1, speed=FanSpeed.MEDIUM, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -99,7 +99,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -110,7 +110,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -120,7 +120,7 @@ async def test_fan_entity_with_all_features_old_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -130,7 +130,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -182,7 +182,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=1, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -191,7 +193,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -200,7 +204,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -209,7 +215,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=4, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -218,7 +226,7 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -227,7 +235,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=4, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -236,7 +246,7 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -245,7 +255,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -254,7 +266,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=False, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -264,7 +278,7 @@ async def test_fan_entity_with_all_features_new_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.FORWARD)] + [call(key=1, direction=FanDirection.FORWARD, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -275,7 +289,7 @@ async def test_fan_entity_with_all_features_new_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.REVERSE)] + [call(key=1, direction=FanDirection.REVERSE, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -285,7 +299,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PRESET_MODE: "Preset1"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.assert_has_calls( + [call(key=1, preset_mode="Preset1", device_id=0)] + ) mock_client.fan_command.reset_mock() @@ -326,7 +342,7 @@ async def test_fan_entity_with_no_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=True, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -335,5 +351,5 @@ async def test_fan_entity_with_no_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 34ada36a4f8..4377a714b17 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -81,7 +81,7 @@ async def test_light_on_off( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -123,7 +123,14 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() @@ -140,6 +147,7 @@ async def test_light_brightness( state=True, color_mode=LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -152,7 +160,7 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, transition_length=2.0)] + [call(key=1, state=False, transition_length=2.0, device_id=0)] ) mock_client.light_command.reset_mock() @@ -163,7 +171,7 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, flash_length=10.0)] + [call(key=1, state=False, flash_length=10.0, device_id=0)] ) mock_client.light_command.reset_mock() @@ -180,6 +188,7 @@ async def test_light_brightness( state=True, transition_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -198,6 +207,7 @@ async def test_light_brightness( state=True, flash_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -248,7 +258,14 @@ async def test_light_legacy_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() @@ -303,6 +320,7 @@ async def test_light_brightness_on_off( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS.value, + device_id=0, ) ] ) @@ -321,6 +339,7 @@ async def test_light_brightness_on_off( state=True, color_mode=ESPColorMode.BRIGHTNESS.value, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -375,6 +394,7 @@ async def test_light_legacy_white_converted_to_brightness( color_mode=LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + device_id=0, ) ] ) @@ -439,6 +459,7 @@ async def test_light_legacy_white_with_rgb( brightness=pytest.approx(0.23529411764705882), white=1.0, color_mode=color_mode, + device_id=0, ) ] ) @@ -496,6 +517,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( key=1, state=True, color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN, + device_id=0, ) ] ) @@ -514,6 +536,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -560,7 +583,7 @@ async def test_light_on_and_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -618,6 +641,7 @@ async def test_rgb_color_temp_light( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS, + device_id=0, ) ] ) @@ -636,6 +660,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -654,6 +679,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.COLOR_TEMPERATURE, color_temperature=400, + device_id=0, ) ] ) @@ -706,6 +732,7 @@ async def test_light_rgb( color_mode=LightColorCapability.RGB | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -726,6 +753,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -752,6 +780,7 @@ async def test_light_rgb( | LightColorCapability.BRIGHTNESS, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -773,6 +802,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -843,6 +873,7 @@ async def test_light_rgbw( | LightColorCapability.WHITE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -864,6 +895,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -892,6 +924,7 @@ async def test_light_rgbw( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -915,6 +948,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -938,6 +972,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1017,6 +1052,7 @@ async def test_light_rgbww_with_cold_warm_white_support( key=1, state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, + device_id=0, ) ] ) @@ -1035,6 +1071,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1059,6 +1096,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_mode=ESPColorMode.RGB, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1078,6 +1116,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_brightness=1.0, color_mode=ESPColorMode.RGB, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1098,6 +1137,7 @@ async def test_light_rgbww_with_cold_warm_white_support( white=1, color_mode=ESPColorMode.RGB_WHITE, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1122,6 +1162,7 @@ async def test_light_rgbww_with_cold_warm_white_support( warm_white=1, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1140,6 +1181,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_temperature=400.0, color_mode=ESPColorMode.COLOR_TEMPERATURE, + device_id=0, ) ] ) @@ -1217,6 +1259,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1239,6 +1282,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1268,6 +1312,7 @@ async def test_light_rgbww_without_cold_warm_white_support( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1294,6 +1339,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1319,6 +1365,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1347,6 +1394,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1372,6 +1420,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -1437,6 +1486,7 @@ async def test_light_color_temp( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1448,7 +1498,7 @@ async def test_light_color_temp( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1511,6 +1561,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1531,6 +1582,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1542,7 +1594,7 @@ async def test_light_color_temp_no_mireds_set( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1615,6 +1667,7 @@ async def test_light_color_temp_legacy( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1626,7 +1679,7 @@ async def test_light_color_temp_legacy( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1695,6 +1748,7 @@ async def test_light_rgb_legacy( call( key=1, state=True, + device_id=0, ) ] ) @@ -1706,7 +1760,7 @@ async def test_light_rgb_legacy( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() await hass.services.async_call( @@ -1722,6 +1776,7 @@ async def test_light_rgb_legacy( state=True, rgb=(1.0, 1.0, 1.0), color_brightness=1.0, + device_id=0, ) ] ) @@ -1780,6 +1835,7 @@ async def test_light_effects( state=True, color_mode=ESPColorMode.BRIGHTNESS, effect="effect1", + device_id=0, ) ] ) @@ -1843,7 +1899,7 @@ async def test_only_cold_warm_white_support( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=color_modes)] + [call(key=1, state=True, color_mode=color_modes, device_id=0)] ) mock_client.light_command.reset_mock() @@ -1860,6 +1916,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1878,6 +1935,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, color_temperature=400.0, + device_id=0, ) ] ) @@ -1922,5 +1980,7 @@ async def test_light_no_color_modes( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=0, device_id=0)] + ) mock_client.light_command.reset_mock() diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index ab16311fc68..eaa03947a7d 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -57,7 +57,7 @@ async def test_lock_entity_no_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() @@ -122,7 +122,7 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() await hass.services.async_call( @@ -131,7 +131,9 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) + mock_client.lock_command.assert_has_calls( + [call(1, LockCommand.UNLOCK, None, device_id=0)] + ) mock_client.lock_command.reset_mock() await hass.services.async_call( @@ -140,4 +142,4 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN, device_id=0)]) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index ecd0ec4cb8b..6d7a3b220d1 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -85,7 +85,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -99,7 +99,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -112,7 +112,9 @@ async def test_media_player_entity( }, blocking=True, ) - mock_client.media_player_command.assert_has_calls([call(1, volume=0.5)]) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.5, device_id=0)] + ) mock_client.media_player_command.reset_mock() await hass.services.async_call( @@ -124,7 +126,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PAUSE)] + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -137,7 +139,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PLAY)] + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -150,7 +152,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.STOP)] + [call(1, command=MediaPlayerCommand.STOP, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -257,7 +259,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="http://www.example.com/xy.mp3", announcement=None)] + [ + call( + 1, + media_url="http://www.example.com/xy.mp3", + announcement=None, + device_id=0, + ) + ] ) client = await hass_ws_client() @@ -284,7 +293,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="media-source://tts?message=hello", announcement=True)] + [ + call( + 1, + media_url="media-source://tts?message=hello", + announcement=True, + device_id=0, + ) + ] ) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 932d86c70e3..d7a59222d47 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -60,7 +60,7 @@ async def test_generic_number_entity( {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, blocking=True, ) - mock_client.number_command.assert_has_calls([call(1, 50)]) + mock_client.number_command.assert_has_calls([call(1, 50, device_id=0)]) mock_client.number_command.reset_mock() diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index a30075b5833..6b7415889d8 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -89,7 +89,7 @@ async def test_select_generic_entity( {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, blocking=True, ) - mock_client.select_command.assert_has_calls([call(1, "b")]) + mock_client.select_command.assert_has_calls([call(1, "b", device_id=0)]) async def test_wake_word_select_no_wake_words( diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index 0efb3d86256..c62101125bd 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -2,17 +2,17 @@ from unittest.mock import call -from aioesphomeapi import APIClient, SwitchInfo, SwitchState +from aioesphomeapi import APIClient, SubDeviceInfo, SwitchInfo, SwitchState from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from .conftest import MockGenericDeviceEntryType +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType async def test_switch_generic_entity( @@ -47,7 +47,7 @@ async def test_switch_generic_entity( {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, True)]) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) await hass.services.async_call( SWITCH_DOMAIN, @@ -55,4 +55,95 @@ async def test_switch_generic_entity( {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, False)]) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) + + +async def test_switch_sub_device_non_zero_device_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test switch on sub-device with non-zero device_id passes through to API.""" + # Create sub-device + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device", area_id=0), + ] + device_info = { + "name": "test", + "devices": sub_devices, + } + # Create switches on both main device and sub-device + entity_info = [ + SwitchInfo( + object_id="main_switch", + key=1, + name="Main Switch", + unique_id="main_switch_1", + device_id=0, # Main device + ), + SwitchInfo( + object_id="sub_switch", + key=2, + name="Sub Switch", + unique_id="sub_switch_1", + device_id=11111111, # Sub-device + ), + ] + # States for both switches + states = [ + SwitchState(key=1, state=True, device_id=0), + SwitchState(key=2, state=False, device_id=11111111), + ] + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist with correct states + main_state = hass.states.get("switch.test_main_switch") + assert main_state is not None + assert main_state.state == STATE_ON + + sub_state = hass.states.get("switch.sub_device_sub_switch") + assert sub_state is not None + assert sub_state.state == STATE_OFF + + # Test turning on the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, True, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test turning off the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, False, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test main device switch still uses device_id=0 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) + mock_client.switch_command.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index c8a7b2b9b45..f8c1d33e224 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -51,7 +51,7 @@ async def test_generic_text_entity( {ATTR_ENTITY_ID: "text.test_my_text", ATTR_VALUE: "goodbye"}, blocking=True, ) - mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.assert_has_calls([call(1, "goodbye", device_id=0)]) mock_client.text_command.reset_mock() diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 9342bd16055..75e2a0dc664 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -47,7 +47,7 @@ async def test_generic_time_entity( {ATTR_ENTITY_ID: "time.test_my_time", ATTR_TIME: "01:23:45"}, blocking=True, ) - mock_client.time_command.assert_has_calls([call(1, 1, 23, 45)]) + mock_client.time_command.assert_has_calls([call(1, 1, 23, 45, device_id=0)]) mock_client.time_command.reset_mock() diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index fd852949e65..96b77281485 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -544,7 +544,9 @@ async def test_generic_device_update_entity_has_update( assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - mock_client.update_command.assert_called_with(key=1, command=UpdateCommand.CHECK) + mock_client.update_command.assert_called_with( + key=1, command=UpdateCommand.CHECK, device_id=0 + ) async def test_update_entity_release_notes( diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index d31e2bfb09e..aaa52551115 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -66,7 +66,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -75,7 +75,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -84,7 +84,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve", ATTR_POSITION: 50}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -93,7 +93,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( @@ -164,7 +164,7 @@ async def test_valve_entity_without_position( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -173,7 +173,7 @@ async def test_valve_entity_without_position( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( From 4122af1d3322be8674cd993c743164a1ae355980 Mon Sep 17 00:00:00 2001 From: Alex Leversen <91166616+leversonic@users.noreply.github.com> Date: Sun, 13 Jul 2025 03:04:01 -0400 Subject: [PATCH 0122/1118] Bump pyoctoprintapi version to 0.1.14 (#148651) --- homeassistant/components/octoprint/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/octoprint/__init__.py | 2 +- tests/components/octoprint/test_sensor.py | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index 005cf5305d9..25e4062373c 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "iot_class": "local_polling", "loggers": ["pyoctoprintapi"], - "requirements": ["pyoctoprintapi==0.1.12"], + "requirements": ["pyoctoprintapi==0.1.14"], "ssdp": [ { "manufacturer": "The OctoPrint Project", diff --git a/requirements_all.txt b/requirements_all.txt index fe66f48a42a..72e86bc3324 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2196,7 +2196,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.ombi pyombi==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0e3b62646..9a846910eb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1829,7 +1829,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.openuv pyopenuv==2023.02.0 diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index dd3eda0e81f..3ddae7de587 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 8c1c0a7712e..87485e46807 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -24,7 +24,7 @@ async def test_sensors( "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Printing", } @@ -126,7 +126,7 @@ async def test_sensors_paused( "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } @@ -155,7 +155,7 @@ async def test_sensors_printer_disconnected( ) -> None: """Test the underlying sensors.""" job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } From d22dd68119c0673dd9b046e7d3347561a1882af4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 13 Jul 2025 10:37:48 +0200 Subject: [PATCH 0123/1118] Fix exception in EntityRegistry.async_device_modified (#148645) --- homeassistant/helpers/entity_registry.py | 3 ++ tests/helpers/test_entity_registry.py | 64 +++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ddb25c7b0a8..7051521b805 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1113,6 +1113,9 @@ class EntityRegistry(BaseRegistry): ): self.async_remove(entity.entity_id) else: + if entity.entity_id not in self.entities: + # Entity has been removed already, skip it + continue self.async_update_entity(entity.entity_id, device_id=None) return diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 40a26295cbb..e403333d8df 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -16,9 +16,10 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -1985,6 +1986,67 @@ async def test_update_device_race( assert not entity_registry.async_is_registered(entry.entity_id) +async def test_update_device_race_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test race when a device is removed. + + This test simulates the behavior of helpers which are removed when the + source entity is removed. + """ + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Create device + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + # Add entity to the device, from the same config entry + entry_same_config_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + # Add entity to the device, not from the same config entry + entry_no_config_entry = entity_registry.async_get_or_create( + "light", + "helper", + "abcd", + device_id=device_entry.id, + ) + # Add a third entity to the device, from the same config entry + entry_same_config_entry_2 = entity_registry.async_get_or_create( + "sensor", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Add a listener to remove the 2nd entity it when 1st entity is removed + @callback + def on_entity_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + if event.data["action"] == "remove": + entity_registry.async_remove(entry_no_config_entry.entity_id) + + async_track_entity_registry_updated_event( + hass, entry_same_config_entry.entity_id, on_entity_event + ) + + device_registry.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + assert not entity_registry.async_is_registered(entry_same_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_no_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_same_config_entry_2.entity_id) + + async def test_disable_device_disables_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From bb17f34bae8a32b25da5151398a8e27d2f309185 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 13 Jul 2025 21:01:38 +1000 Subject: [PATCH 0124/1118] Remove history first refresh from Teslemetry (#148531) --- .../components/teslemetry/__init__.py | 5 --- .../components/teslemetry/coordinator.py | 1 + .../teslemetry/snapshots/test_sensor.ambr | 42 +++++++++---------- .../components/teslemetry/test_diagnostics.py | 9 ++++ tests/components/teslemetry/test_init.py | 14 ------- tests/components/teslemetry/test_sensor.py | 6 ++- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 49af8c1a08d..3ffc6c43efb 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -215,11 +215,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), - *( - energysite.history_coordinator.async_config_entry_first_refresh() - for energysite in energysites - if energysite.history_coordinator - ), ) # Add energy device models diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index e6b453402e9..eed00ebc64f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -183,6 +183,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=ENERGY_HISTORY_INTERVAL, ) self.api = api + self.data = {} async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 57a0f49d949..1db8cf9612f 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -130,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -355,7 +355,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -580,7 +580,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -655,7 +655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -730,7 +730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -805,7 +805,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.038', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -955,7 +955,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1180,7 +1180,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1330,7 +1330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1555,7 +1555,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1630,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1780,7 +1780,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.074', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -2087,7 +2087,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2162,7 +2162,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index fb8eb79a918..18182b14321 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,11 +1,14 @@ """Test the Telemetry Diagnostics.""" +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.core import HomeAssistant from . import setup_platform +from tests.common import async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -14,10 +17,16 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostics.""" entry = await setup_platform(hass) + # Wait for coordinator refresh + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index d2ef5c38893..54c9ca0dad9 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -107,20 +107,6 @@ async def test_energy_site_refresh_error( assert entry.state is state -# Test Energy History Coordinator -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_energy_history_refresh_error( - hass: HomeAssistant, - mock_energy_history: AsyncMock, - side_effect: TeslaFleetError, - state: ConfigEntryState, -) -> None: - """Test coordinator refresh with an error.""" - mock_energy_history.side_effect = side_effect - entry = await setup_platform(hass) - assert entry.state is state - - async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index d2d6d88b3e3..296f9e8bff4 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -9,7 +9,7 @@ from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,6 +30,8 @@ async def test_sensors( """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() # Force the vehicle to use polling with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): @@ -117,7 +119,7 @@ async def test_energy_history_no_time_series( entity_id = "sensor.energy_site_battery_discharged" state = hass.states.get(entity_id) - assert state.state == "0.036" + assert state.state == STATE_UNKNOWN mock_energy_history.return_value = ENERGY_HISTORY_EMPTY From f7d132b043c17f225f3716f19e083ba1d7ac853d Mon Sep 17 00:00:00 2001 From: Steven Tegreeny Date: Sun, 13 Jul 2025 07:46:37 -0400 Subject: [PATCH 0125/1118] Add Z-WAVE discovery entry for the GE/JASCO in-wall smart fan control (#148246) --- .../components/zwave_js/discovery.py | 10 +- tests/components/zwave_js/conftest.py | 14 + .../enbrighten_58446_zwa4013_state.json | 1116 +++++++++++++++++ tests/components/zwave_js/test_discovery.py | 14 + 4 files changed, 1151 insertions(+), 3 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3b541a733cc..74ffedbc53f 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -263,7 +263,7 @@ WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( ) # For device class mapping see: -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/ DISCOVERY_SCHEMAS = [ # ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS ======= # Honeywell 39358 In-Wall Fan Control using switch multilevel CC @@ -291,12 +291,16 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002 + # GE/Jasco - In-Wall Smart Fan Controls ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", manufacturer_id={0x0063}, - product_id={0x3131, 0x3337}, + product_id={ + 0x3131, + 0x3337, # 14287 / 55258 / ZW4002 + 0x3533, # 58446 / ZWA4013 + }, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=FixedFanValueMappingDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 138bcd63ede..1163da4971c 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -325,6 +325,12 @@ def ge_12730_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) +@pytest.fixture(name="enbrighten_58446_zwa4013_state", scope="package") +def enbrighten_58446_zwa4013_state_fixture() -> dict[str, Any]: + """Load the Enbrighten/GE 58446/zwa401 node state fixture data.""" + return load_json_object_fixture("enbrighten_58446_zwa4013_state.json", DOMAIN) + + @pytest.fixture(name="aeotec_radiator_thermostat_state", scope="package") def aeotec_radiator_thermostat_state_fixture() -> dict[str, Any]: """Load the Aeotec Radiator Thermostat node state fixture data.""" @@ -1078,6 +1084,14 @@ def ge_12730_fixture(client, ge_12730_state) -> Node: return node +@pytest.fixture(name="enbrighten_58446_zwa4013") +def enbrighten_58446_zwa4013_fixture(client, enbrighten_58446_zwa4013_state) -> Node: + """Mock a Enbrighten_58446/zwa4013 fan controller node.""" + node = Node(client, copy.deepcopy(enbrighten_58446_zwa4013_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="inovelli_lzw36") def inovelli_lzw36_fixture(client, inovelli_lzw36_state) -> Node: """Mock a Inovelli LZW36 fan controller node.""" diff --git a/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json new file mode 100644 index 00000000000..dd580a9b43b --- /dev/null +++ b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json @@ -0,0 +1,1116 @@ +{ + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 99, + "productId": 13619, + "productType": 18756, + "firmwareVersion": "1.26.1", + "zwavePlusVersion": 2, + "name": "zwa4013_fan", + "deviceConfig": { + "manufacturer": "Enbrighten", + "manufacturerId": 99, + "label": "58446 / ZWA4013", + "description": "In-Wall Fan Speed Control, QFSW, 700S", + "devices": [ + { + "productType": 18756, + "productId": 13619 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "mapBasicSet": "event" + }, + "metadata": { + "inclusion": "1. Follow the instructions for your Z-Wave certified Controller to add a device to the Z-Wave network.\n2. Once the controller is ready to add your device, press the top of bottom of the wireless smart Fan controller", + "exclusion": "1. Follow the instructions for your Z-Wave certified controller to remove a device from the Z-wave network\n2. Once the controller is ready to remove your device, press the top or bottom of the wireless smart Fan controller", + "reset": "Pull the airgap switch. Press and hold the bottom button, push the airgap switch in and continue holding the bottom button for 10 seconds. The LED will flash once each of the 8 colors then stop" + } + }, + "label": "58446 / ZWA4013", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3533:1.26.1", + "statistics": { + "commandsTX": 158, + "commandsRX": 154, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 30.1, + "lastSeen": "2025-07-05T19:10:23.100Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-07-05T19:10:23.100Z", + "protocol": 0, + "sdkVersion": "7.18.1", + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "3-Way Setup", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Setup", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Add-on", + "1": "Standard" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Alternate Exclusion", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Press MENU button once", + "label": "Alternate Exclusion", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 5, + "min": 1, + "max": 8, + "states": { + "1": "Red", + "2": "Orange", + "3": "Yellow", + "4": "Green", + "5": "Blue", + "6": "Pink", + "7": "Purple", + "8": "White" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "LED Indicator Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Guidelight Mode Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Guidelight Mode Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Always off", + "1": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Fan Speed Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Fan Speed Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Press and hold", + "1": "Single button presses" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyName": "Reset to Factory Default", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reset to Factory Default", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "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": 13619 + }, + { + "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": 18756 + }, + { + "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": 99 + }, + { + "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": 1 + }, + { + "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": ["1.26"] + }, + { + "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": "7.18" + }, + { + "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": 3 + }, + { + "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": 273 + }, + { + "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": "1.26.1" + }, + { + "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": 273 + }, + { + "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": "7.18.1" + }, + { + "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": "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": "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": 273 + }, + { + "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": "10.18.1" + }, + { + "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": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "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": 3, + "isSecure": true + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "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": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index c8bfca2b35f..44133db03ac 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -98,6 +98,20 @@ async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> N assert state +async def test_enbrighten_58446_zwa4013( + hass: HomeAssistant, client, enbrighten_58446_zwa4013, integration +) -> None: + """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" + node = enbrighten_58446_zwa4013 + assert node.device_class.specific.label == "Multilevel Power Switch" + + state = hass.states.get("light.zwa4013_fan") + assert not state + + state = hass.states.get("fan.zwa4013_fan") + assert state + + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration ) -> None: From 023dd9d523267ac1c686f297168ae1c79ebd46a7 Mon Sep 17 00:00:00 2001 From: Robert Meijers Date: Sun, 13 Jul 2025 16:56:31 +0200 Subject: [PATCH 0126/1118] Discover Heos players using Zeroconf (#144763) --- homeassistant/components/heos/config_flow.py | 92 ++++++------ homeassistant/components/heos/manifest.json | 3 +- homeassistant/generated/zeroconf.py | 5 + tests/components/heos/conftest.py | 32 +++++ tests/components/heos/test_config_flow.py | 139 +++++++++++++++++++ 5 files changed, 229 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index e2d3e2522dc..b6cda10dcb7 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry @@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location - entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovery is ignored or host is part of the current system - if entry and ( - entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) - ): - return self.async_abort(reason="single_instance_allowed") + return await self._async_handle_discovered(hostname) - # Connect to discovered host and get system information - heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) - try: - await heos.connect() - system_info = await heos.get_system_info() - except HeosError as error: - _LOGGER.debug( - "Failed to retrieve system information from discovered HEOS device %s", - hostname, - exc_info=error, - ) - return self.async_abort(reason="cannot_connect") - finally: - await heos.disconnect() - - # Select the preferred host, if available - if system_info.preferred_hosts: - hostname = system_info.preferred_hosts[0].ip_address - - # Move to confirmation when not configured - if entry is None: - self._discovered_host = hostname - return await self.async_step_confirm_discovery() - - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload - if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: - _LOGGER.debug( - "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname - ) - return self.async_update_reload_and_abort( - entry, - data_updates={CONF_HOST: hostname}, - reason="reconfigure_successful", - ) - return self.async_abort(reason="single_instance_allowed") + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + return await self._async_handle_discovered(discovery_info.host) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -267,6 +233,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ), ) + async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult: + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address: + hostname = system_info.preferred_hosts[0].ip_address + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") + class HeosOptionsFlowHandler(OptionsFlow): """Define HEOS options flow.""" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 8a88913456d..99cedf56f1f 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -13,5 +13,6 @@ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } - ] + ], + "zeroconf": ["_heos-audio._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 274fafa51cf..47522a69c41 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -534,6 +534,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_heos-audio._tcp.local.": [ + { + "domain": "heos", + }, + ], "_homeconnect._tcp.local.": [ { "domain": "home_connect", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 835e4436398..e72c72c7334 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from ipaddress import ip_address from unittest.mock import Mock, patch from pyheos import ( @@ -39,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -284,6 +286,36 @@ def discovery_data_fixture_bedroom() -> SsdpServiceInfo: ) +@pytest.fixture(name="zeroconf_discovery_data") +def zeroconf_discovery_data_fixture() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.1" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenon._heos-audio._tcp.local.", + properties={}, + ) + + +@pytest.fixture(name="zeroconf_discovery_data_bedroom") +def zeroconf_discovery_data_fixture_bedroom() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.2" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenonBedroom._heos-audio._tcp.local.", + properties={}, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69d9aa3a38e..4749dc48b01 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -18,12 +18,14 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_SSDP, SOURCE_USER, + SOURCE_ZEROCONF, ConfigEntryState, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -244,6 +246,143 @@ async def test_discovery_updates( assert config_entry.data[CONF_HOST] == "127.0.0.2" +async def test_zeroconf_discovery( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + + +async def test_zeroconf_discovery_flow_aborts_already_setup( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test discovery flow aborts when entry already setup and hosts didn't change.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_aborts_same_system( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_ignored_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_discovery_fails_to_connect_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + controller: MockHeos, +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_zeroconf_discovery_updates( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: From f3ad6bd9b63cb81683a9394a2d02417c689784bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 17:55:24 +0200 Subject: [PATCH 0127/1118] Report correctly when no funds for OpenAI (#148677) --- .../components/openai_conversation/entity.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 97f3bd0ccfe..db14480ec5f 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -362,19 +362,26 @@ class OpenAIBaseLLMEntity(Entity): try: result = await client.responses.create(**model_args) + + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: + if ( + isinstance(err, openai.APIError) + and err.type == "insufficient_quota" + ): + LOGGER.error("Insufficient funds for OpenAI: %s", err) + raise HomeAssistantError("Insufficient funds for OpenAI") from err + LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - if not chat_log.unresponded_tool_results: break From 23a8442abec4468d2a5d9658031d8c9a9d6eeb5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 19:35:11 +0200 Subject: [PATCH 0128/1118] Make attachments native to chat log (#148693) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/ai_task/__init__.py | 3 +- homeassistant/components/ai_task/entity.py | 4 ++- homeassistant/components/ai_task/task.py | 33 +++++++------------ .../components/conversation/__init__.py | 2 ++ .../components/conversation/chat_log.py | 19 +++++++++++ .../ai_task.py | 2 +- .../entity.py | 11 ++----- .../ai_task/snapshots/test_task.ambr | 1 + tests/components/ai_task/test_init.py | 6 ++-- .../snapshots/test_conversation.ambr | 1 + .../test_ai_task.py | 2 +- .../snapshots/test_conversation.ambr | 2 ++ 12 files changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index a472b0db131..a16e11c05d7 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -33,7 +33,7 @@ from .const import ( ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data +from .task import GenDataTask, GenDataTaskResult, async_generate_data __all__ = [ "DOMAIN", @@ -41,7 +41,6 @@ __all__ = [ "AITaskEntityFeature", "GenDataTask", "GenDataTaskResult", - "PlayMediaWithId", "async_generate_data", "async_setup", "async_setup_entry", diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index cb6094cba4e..420777ce5c3 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -79,7 +79,9 @@ class AITaskEntity(RestoreEntity): user_llm_prompt=DEFAULT_SYSTEM_PROMPT, ) - chat_log.async_add_user_content(UserContent(task.instructions)) + chat_log.async_add_user_content( + UserContent(task.instructions, attachments=task.attachments) + ) yield chat_log diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 72d1018210c..bb57a89203e 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -2,30 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass from typing import Any import voluptuous as vol -from homeassistant.components import media_source +from homeassistant.components import conversation, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature -@dataclass(slots=True) -class PlayMediaWithId(media_source.PlayMedia): - """Play media with a media content ID.""" - - media_content_id: str - """Media source ID to play.""" - - def __str__(self) -> str: - """Return media source ID as a string.""" - return f"" - - async def async_generate_data( hass: HomeAssistant, *, @@ -52,7 +40,7 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[PlayMediaWithId] | None = None + resolved_attachments: list[conversation.Attachment] | None = None if attachments: if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: @@ -66,13 +54,16 @@ async def async_generate_data( media = await media_source.async_resolve_media( hass, attachment["media_content_id"], None ) + if media.path is None: + raise HomeAssistantError( + "Only local attachments are currently supported" + ) resolved_attachments.append( - PlayMediaWithId( - **{ - field.name: getattr(media, field.name) - for field in fields(media) - }, + conversation.Attachment( media_content_id=attachment["media_content_id"], + url=media.url, + mime_type=media.mime_type, + path=media.path, ) ) @@ -99,7 +90,7 @@ class GenDataTask: structure: vol.Schema | None = None """Optional structure for the data to be generated.""" - attachments: list[PlayMediaWithId] | None = None + attachments: list[conversation.Attachment] | None = None """List of attachments to go along the instructions.""" def __str__(self) -> str: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 66a5735e6b6..ec866604205 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -34,6 +34,7 @@ from .agent_manager import ( from .chat_log import ( AssistantContent, AssistantContentDeltaDict, + Attachment, ChatLog, Content, ConverseError, @@ -66,6 +67,7 @@ __all__ = [ "HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", + "Attachment", "ChatLog", "Content", "ConversationEntity", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 6322bdb4435..e8ec66afa76 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,6 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging +from pathlib import Path from typing import Any, Literal, TypedDict import voluptuous as vol @@ -136,6 +137,24 @@ class UserContent: role: Literal["user"] = field(init=False, default="user") content: str + attachments: list[Attachment] | None = field(default=None) + + +@dataclass(frozen=True) +class Attachment: + """Attachment for a chat message.""" + + media_content_id: str + """Media content ID of the attachment.""" + + url: str + """URL of the attachment.""" + + mime_type: str + """MIME type of the attachment.""" + + path: Path + """Path to the attachment on disk.""" @dataclass(frozen=True) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index 80d5a1dfa06..4ffca835fed 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -48,7 +48,7 @@ class GoogleGenerativeAITaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure, task.attachments) + await self._async_handle_chat_log(chat_log, task.structure) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fce1fdd40e7..8e967d84517 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -30,7 +30,7 @@ from google.genai.types import ( import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components import ai_task, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -338,7 +338,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity): self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, - attachments: list[ai_task.PlayMediaWithId] | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -442,15 +441,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity): user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) chat_request: str | list[Part] = user_message.content - if attachments: - if any(a.path is None for a in attachments): - raise HomeAssistantError( - "Only local attachments are currently supported" - ) + if user_message.attachments: files = await async_prepare_files_for_prompt( self.hass, self._genai_client, - [a.path for a in attachments], # type: ignore[misc] + [a.path for a in user_message.attachments], ) chat_request = [chat_request, *files] diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 3b40b0632a6..181fc383d64 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -9,6 +9,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Test prompt', 'role': 'user', }), diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 840285493ac..19f73045532 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,5 +1,6 @@ """Test initialization of the AI Task component.""" +from pathlib import Path from typing import Any from unittest.mock import patch @@ -89,6 +90,7 @@ async def test_generate_data_service( return_value=media_source.PlayMedia( url="http://example.com/media.mp4", mime_type="video/mp4", + path=Path("media.mp4"), ), ): result = await hass.services.async_call( @@ -118,9 +120,7 @@ async def test_generate_data_service( assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] - assert ( - str(attachment) == f"" - ) + assert attachment.path == Path("media.mp4") async def test_generate_data_service_structure_fields( diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 09618b135db..d97eaab41e4 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -12,6 +12,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 653b41fcb6e..6326bd94ad9 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -185,7 +185,7 @@ async def test_generate_data( ) assert result.data == {"characters": ["Mario", "Luigi"]} - assert len(mock_chat_create.mock_calls) == 4 + assert len(mock_chat_create.mock_calls) == 3 config = mock_chat_create.mock_calls[-1][2]["config"] assert config.response_mime_type == "application/json" assert config.response_schema == { diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 48ad0878b2f..77c52ab97e6 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -2,6 +2,7 @@ # name: test_function_call list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), @@ -58,6 +59,7 @@ # name: test_function_call_without_reasoning list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), From 611f86cf8c1a89b33baeff902563476dbdea564a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 21:51:46 +0200 Subject: [PATCH 0129/1118] OpenAI: Add attachment support to AI task (#148676) --- .../components/openai_conversation/ai_task.py | 5 +- .../components/openai_conversation/entity.py | 20 +++++ .../openai_conversation/test_ai_task.py | 88 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index ff8c6e62520..5fc700a73ad 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -39,7 +39,10 @@ class OpenAITaskEntity( ): """OpenAI AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index db14480ec5f..7679bef83f1 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -345,6 +345,26 @@ class OpenAIBaseLLMEntity(Entity): for content in chat_log.content for m in _convert_content_to_param(content) ] + + last_content = chat_log.content[-1] + + # Handle attachments by adding them to the last user message + if last_content.role == "user" and last_content.attachments: + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + last_message = messages[-1] + assert ( + last_message["type"] == "message" + and last_message["role"] == "user" + and isinstance(last_message["content"], str) + ) + last_message["content"] = [ + {"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item] + *files, # type: ignore[list-item] + ] + if structure and structure_name: model_args["text"] = { "format": { diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 4541e11f5f8..14e3056c0e2 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -1,11 +1,12 @@ """Test AI Task platform of OpenAI Conversation integration.""" -from unittest.mock import AsyncMock +from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -122,3 +123,86 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachments( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with attachments.""" + entity_id = "ai_task.openai_ai_task" + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi there!", output_index=0) + ] + + # Test with attachments + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + # patch.object(hass.config, "is_allowed_path", return_value=True), + patch( + "homeassistant.components.openai_conversation.entity.guess_file_type", + return_value=("image/jpeg", None), + ), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + assert result.data == "Hi there!" + + # Verify that the create stream was called with the correct parameters + # The last call should have the user message with attachments + call_args = mock_create_stream.call_args + assert call_args is not None + + # Check that the input includes the attachments + input_messages = call_args[1]["input"] + assert len(input_messages) > 0 + + # Find the user message with attachments + user_message_with_attachments = input_messages[-2] + + assert user_message_with_attachments is not None + assert isinstance(user_message_with_attachments["content"], list) + assert len(user_message_with_attachments["content"]) == 3 # Text + attachments + assert user_message_with_attachments["content"] == [ + {"type": "input_text", "text": "Test prompt"}, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + ] From b2fe17c6d47a09d84ea21ebe048bdab63417feb0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:12:00 +0200 Subject: [PATCH 0130/1118] Update PyMicroBot to 0.0.23 (#148700) --- .../components/keymitt_ble/__init__.py | 32 ++----------------- .../components/keymitt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 5 files changed, 5 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 0f71519e420..01948006852 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,42 +2,14 @@ from __future__ import annotations -from collections.abc import Generator -from contextlib import contextmanager - -import bleak +from microbot import MicroBotApiClient from homeassistant.components import bluetooth from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady - -@contextmanager -def patch_unused_bleak_discover_import() -> Generator[None]: - """Patch bleak.discover import in microbot. It is unused and was removed in bleak 1.0.0.""" - - def getattr_bleak(name: str) -> object: - if name == "discover": - return None - raise AttributeError - - original_func = bleak.__dict__.get("__getattr__") - bleak.__dict__["__getattr__"] = getattr_bleak - try: - yield - finally: - if original_func is not None: - bleak.__dict__["__getattr__"] = original_func - - -with patch_unused_bleak_discover_import(): - from microbot import MicroBotApiClient - -from .coordinator import ( # noqa: E402 - MicroBotConfigEntry, - MicroBotDataUpdateCoordinator, -) +from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator PLATFORMS: list[str] = [Platform.SWITCH] diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 5abdfe5b4a7..249bb5eb121 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.17"] + "requirements": ["PyMicroBot==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72e86bc3324..5b9322b39ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a846910eb3..a079b52ce17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/script/licenses.py b/script/licenses.py index 6d5f7e58f2f..d7819cba536 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -178,7 +178,6 @@ OSI_APPROVED_LICENSES = { } EXCEPTIONS = { - "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL From 74288a3bc8a63061fa7a0c5ccbedd3e489052564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 13 Jul 2025 22:46:42 +0200 Subject: [PATCH 0131/1118] Re-enable Home Connect updates automatically (#148657) Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 46 ++++++------ .../components/home_connect/strings.json | 11 --- .../home_connect/test_coordinator.py | 74 +++++++++++-------- 3 files changed, 67 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index bb419f6bd7c..81f785b55ae 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -38,7 +38,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -626,39 +626,37 @@ class HomeConnectCoordinator( """Check if the appliance data hasn't been refreshed too often recently.""" now = self.hass.loop.time() - if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: - return True + + execution_tracker = self._execution_tracker[appliance_ha_id] + initial_len = len(execution_tracker) execution_tracker = self._execution_tracker[appliance_ha_id] = [ timestamp - for timestamp in self._execution_tracker[appliance_ha_id] + for timestamp in execution_tracker if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW ] execution_tracker.append(now) if len(execution_tracker) >= MAX_EXECUTIONS: - ir.async_create_issue( - self.hass, - DOMAIN, - f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="home_connect_too_many_connected_paired_events", - data={ - "entry_id": self.config_entry.entry_id, - "appliance_ha_id": appliance_ha_id, - }, - translation_placeholders={ - "appliance_name": self.data[appliance_ha_id].info.name, - "times": str(MAX_EXECUTIONS), - "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), - "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", - }, - ) + if initial_len < MAX_EXECUTIONS: + _LOGGER.warning( + 'Too many connected/paired events for appliance "%s" ' + "(%s times in less than %s minutes), updates have been disabled " + "and they will be enabled again whenever the connection stabilizes. " + "Consider trying to unplug the appliance " + "for a while to perform a soft reset", + self.data[appliance_ha_id].info.name, + MAX_EXECUTIONS, + MAX_EXECUTIONS_TIME_WINDOW // 60, + ) return True + if initial_len >= MAX_EXECUTIONS: + _LOGGER.info( + 'Connected/paired events from the appliance "%s" have stabilized,' + " updates have been re-enabled", + self.data[appliance_ha_id].info.name, + ) return False diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index e1c0b42ca0b..853d2bd2f8e 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -124,17 +124,6 @@ } }, "issues": { - "home_connect_too_many_connected_paired_events": { - "title": "{appliance_name} sent too many connected or paired events", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." - } - } - } - }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index f9fed995b89..a368cfbef2d 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -53,16 +52,11 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -580,8 +574,7 @@ async def test_paired_disconnected_devices_not_fetching( async def test_coordinator_disabling_updates_for_appliance( hass: HomeAssistant, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -592,7 +585,6 @@ async def test_coordinator_disabling_updates_for_appliance( When the user confirms the issue the updates should be enabled again. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -606,13 +598,26 @@ async def test_coordinator_disabling_updates_for_appliance( EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(8) + for _ in range(6) ] ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue + freezer.tick(timedelta(minutes=10)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(2) + ] + ) + await hass.async_block_till_done() + + # At this point, the updates have been blocked because + # 6 + 2 connected events have been received in less than an hour get_settings_original_side_effect = client.get_settings.side_effect @@ -644,18 +649,36 @@ async def test_coordinator_disabling_updates_for_appliance( assert hass.states.is_state("switch.dishwasher_power", STATE_ON) - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, + # After 55 minutes, the updates should be enabled again + # because one hour has passed since the first connect events, + # so there are 2 connected events in the execution_tracker + freezer.tick(timedelta(minutes=55)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + # If more connect events are sent, it should be blocked again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) # 2 + 1 + 5 = 8 connect events in less than an hour + ] + ) + await hass.async_block_till_done() + client.get_settings = get_settings_original_side_effect await client.add_events( [ EventMessage( @@ -672,7 +695,6 @@ async def test_coordinator_disabling_updates_for_appliance( async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( hass: HomeAssistant, - issue_registry: ir.IssueRegistry, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -682,7 +704,6 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r The repair issue should also be deleted. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -701,14 +722,9 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED From cfc7cfcf372b7a73f49aba9b13ee08fd4ad84ce9 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sun, 13 Jul 2025 14:44:55 -0700 Subject: [PATCH 0132/1118] Bump screenlogicpy to 0.10.2 (#148703) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 434b8921bc2..2a91fcd6c8e 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.10.0"] + "requirements": ["screenlogicpy==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5b9322b39ab..35419a46e06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2716,7 +2716,7 @@ sanix==1.0.6 satel-integra==0.3.7 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a079b52ce17..69228f8e11c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2244,7 +2244,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.backup securetar==2025.2.1 From 25ba2437ddaf5dc46a43d9fd8cac7b5bb7bf5a5b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 14 Jul 2025 01:15:50 +0300 Subject: [PATCH 0133/1118] Bump aioshelly to 13.7.2 (#148706) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 1db8dbf55c6..08c9163bb3b 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.1"], + "requirements": ["aioshelly==13.7.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 35419a46e06..a5310b6e804 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69228f8e11c..7850fff15c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 From bc07030304581d7529f0fe49a7f5ac33d2e864d5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Jul 2025 01:18:35 +0300 Subject: [PATCH 0134/1118] Bump aioamazondevices to 3.2.10 (#148709) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 41154d91779..25ad75d0d00 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.2.8"] + "requirements": ["aioamazondevices==3.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index a5310b6e804..282f8770d48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7850fff15c8..845e9783f10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 5e30e6cb916c951107bdc30fa1319d76b186c2da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:02:43 +0200 Subject: [PATCH 0135/1118] Update python-mystrom to 2.4.0 (#148682) --- homeassistant/components/mystrom/manifest.json | 2 +- pyproject.toml | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index eaf9eb6acdc..c5a981dbf46 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==2.2.0"] + "requirements": ["python-mystrom==2.4.0"] } diff --git a/pyproject.toml b/pyproject.toml index 3ea2a9c9f1b..860b4af379d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -568,8 +568,6 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:UserWarning:pysiaalarm.data.data", # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:UserWarning:pymystrom", # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 diff --git a/requirements_all.txt b/requirements_all.txt index 282f8770d48..f623b8ef114 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2474,7 +2474,7 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 845e9783f10..a6ea35dd6f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b334b75451e..9c3f60a827c 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -222,11 +222,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mystrom": { - # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 - # python-mystrom > setuptools - "python-mystrom": {"setuptools"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { From e4359e74c68bb929cab9c0d445a159fe091596d6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:08:54 +0200 Subject: [PATCH 0136/1118] Bump PyViCare to 2.50.0 (#148679) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index fed777e6435..8e632e46efe 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.44.0"] + "requirements": ["PyViCare==2.50.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f623b8ef114..c1c783f3d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6ea35dd6f7..fd21c1d1f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 26d71fcdba1a36c68476d9e8256d87c688e426a8 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 13 Jul 2025 23:17:20 -0700 Subject: [PATCH 0137/1118] Fix derivative migration from 'none' unit_prefix (#147820) --- .../components/derivative/__init__.py | 38 +++++++++++ .../components/derivative/config_flow.py | 3 + homeassistant/components/derivative/sensor.py | 6 +- tests/components/derivative/test_init.py | 64 ++++++++++++++++++- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 0806a8f824d..8fb614a3de4 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant @@ -11,6 +13,8 @@ from homeassistant.helpers.device import ( ) from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" @@ -54,3 +58,37 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + new_options = {**config_entry.options} + + if new_options.get("unit_prefix") == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing + del new_options["unit_prefix"] + + hass.config_entries.async_update_entry( + config_entry, options=new_options, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index dc12e1bbfe2..c90631f3aeb 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -141,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab09c17673c..bfba2f0023c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -123,10 +123,6 @@ async def async_setup_entry( source_entity_id, ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": - # Before we had support for optional selectors, "none" was used for selecting nothing - unit_prefix = None - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: @@ -139,7 +135,7 @@ async def async_setup_entry( time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), unique_id=config_entry.entry_id, unit_of_measurement=None, - unit_prefix=unit_prefix, + unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, max_sub_interval=max_sub_interval, diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 533f91c8a33..1f7d051d27e 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import derivative from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -421,3 +421,65 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.parametrize( + ("unit_prefix", "expect_prefix"), + [ + ({}, None), + ({"unit_prefix": "k"}, "k"), + ({"unit_prefix": "none"}, None), + ], +) +async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: + """Test migration from v1.1 deletes "none" unit_prefix.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + **unit_prefix, + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options["unit_time"] == "min" + assert config_entry.options.get("unit_prefix") == expect_prefix + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From f761f7628add3bd543e6c0366e41ca17e4144ec4 Mon Sep 17 00:00:00 2001 From: MattMorgan <48740594+spycle@users.noreply.github.com> Date: Mon, 14 Jul 2025 07:50:25 +0100 Subject: [PATCH 0138/1118] Minor update to keymitt_ble manifest. (#148708) --- homeassistant/components/keymitt_ble/manifest.json | 4 ++-- homeassistant/generated/integrations.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 249bb5eb121..7b1e133bb6e 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,8 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", - "integration_type": "hub", + "integration_type": "device", "iot_class": "assumed_state", - "loggers": ["keymitt_ble"], + "loggers": ["keymitt_ble", "microbot"], "requirements": ["PyMicroBot==0.0.23"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6bf63b260de..ec790549519 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3252,7 +3252,7 @@ }, "keymitt_ble": { "name": "Keymitt MicroBot Push", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "assumed_state" }, From 5e50c723a7bfcfa56dd4176dafdb98740b4464ac Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 14 Jul 2025 18:29:29 +1000 Subject: [PATCH 0139/1118] Fix Charge Cable binary sensor in Teslemetry (#148675) --- homeassistant/components/teslemetry/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 439df76c838..6905cefdc30 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -125,8 +125,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", - streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value is not None and value != "Unknown") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback(None if value is None else value != "Disconnected") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, From eae9f4f925b9493f57440b62b4bda092487cb6ba Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 14 Jul 2025 10:30:48 +0200 Subject: [PATCH 0140/1118] Wallbox Integration - Add repair action for insufficient rights (#148610) Co-authored-by: Norbert Rittel --- .../components/wallbox/coordinator.py | 51 ++++++++++++++++--- homeassistant/components/wallbox/strings.json | 9 ++++ tests/components/wallbox/test_lock.py | 4 +- tests/components/wallbox/test_number.py | 6 +-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 23b028330d1..4e743b2106b 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,6 +14,7 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -197,7 +198,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE elif eco_smart_mode == 1: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: @@ -228,8 +228,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -256,8 +258,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -313,8 +317,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -379,3 +385,34 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class InsufficientRights(HomeAssistantError): + """Error to indicate there are insufficient right for the user.""" + + def __init__( + self, + *args: object, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + hass: HomeAssistant, + ) -> None: + """Initialize exception.""" + super().__init__( + self, *args, translation_domain, translation_key, translation_placeholders + ) + self.hass = hass + self._create_insufficient_rights_issue() + + def _create_insufficient_rights_issue(self) -> None: + """Creates an issue for insufficient rights.""" + ir.create_issue( + self.hass, + DOMAIN, + "insufficient_rights", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://www.home-assistant.io/integrations/wallbox/#troubleshooting", + translation_key="insufficient_rights", + ) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 13f038d14b6..c59b5389658 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -114,6 +114,12 @@ } } }, + "issues": { + "insufficient_rights": { + "title": "The Wallbox account has insufficient rights.", + "description": "The Wallbox account has insufficient rights to lock/unlock and change the charging power. Please assign the user admin rights in the Wallbox portal." + } + }, "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" @@ -123,6 +129,9 @@ }, "invalid_auth": { "message": "Invalid authentication" + }, + "insufficient_rights": { + "message": "Insufficient rights for Wallbox user" } } } diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index e3c6048e928..3f856ed5dc2 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox.coordinator import InvalidAuth +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -96,7 +96,7 @@ async def test_wallbox_lock_class_error_handling( with ( patch.object(mock_wallbox, "lockCharger", side_effect=http_403_error), patch.object(mock_wallbox, "unlockCharger", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( "lock", diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index cb332d1cb1e..5c77189f264 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox.coordinator import InvalidAuth +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -130,7 +130,7 @@ async def test_wallbox_number_power_class_error_handling( with ( patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( NUMBER_DOMAIN, @@ -202,7 +202,7 @@ async def test_wallbox_number_icp_power_class_error_handling( with ( patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( NUMBER_DOMAIN, From 9f3d890e91046ef85fa733e249b9114640355ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Mon, 14 Jul 2025 10:46:13 +0200 Subject: [PATCH 0141/1118] Bump `pysnmp` to v7 and `brother` to v5 (#129761) Co-authored-by: Maciej Bieniek --- .../components/brother/manifest.json | 2 +- .../components/snmp/device_tracker.py | 60 ++++++++++++------- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/components/snmp/sensor.py | 12 ++-- homeassistant/components/snmp/switch.py | 23 ++++--- homeassistant/components/snmp/util.py | 20 +++---- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/snmp/test_float_sensor.py | 2 +- tests/components/snmp/test_init.py | 6 +- tests/components/snmp/test_integer_sensor.py | 2 +- tests/components/snmp/test_negative_sensor.py | 2 +- tests/components/snmp/test_string_sensor.py | 2 +- tests/components/snmp/test_switch.py | 6 +- 14 files changed, 84 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index fa70f3a5dc5..deae818e2b5 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], - "requirements": ["brother==4.3.1"], + "requirements": ["brother==5.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index f69c844f191..eb963ce6a42 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -7,13 +7,13 @@ import logging from typing import TYPE_CHECKING from pysnmp.error import PySnmpError -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - bulkWalkCmd, - isEndOfMib, + bulk_walk_cmd, + is_end_of_mib, ) import voluptuous as vol @@ -59,7 +59,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - scanner = SnmpScanner(config[DEVICE_TRACKER_DOMAIN]) + scanner = await SnmpScanner.create(config[DEVICE_TRACKER_DOMAIN]) await scanner.async_init(hass) return scanner if scanner.success_init else None @@ -69,8 +69,8 @@ class SnmpScanner(DeviceScanner): """Queries any SNMP capable Access Point for connected devices.""" def __init__(self, config): - """Initialize the scanner and test the target device.""" - host = config[CONF_HOST] + """Initialize the scanner after testing the target device.""" + community = config[CONF_COMMUNITY] baseoid = config[CONF_BASEOID] authkey = config.get(CONF_AUTH_KEY) @@ -78,19 +78,6 @@ class SnmpScanner(DeviceScanner): privkey = config.get(CONF_PRIV_KEY) privproto = DEFAULT_PRIV_PROTOCOL - try: - # Try IPv4 first. - target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT) - except PySnmpError: - # Then try IPv6. - try: - target = Udp6TransportTarget( - (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT - ) - except PySnmpError as err: - _LOGGER.error("Invalid SNMP host: %s", err) - return - if authkey is not None or privkey is not None: if not authkey: authproto = "none" @@ -109,16 +96,43 @@ class SnmpScanner(DeviceScanner): community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION] ) - self._target = target + self._target: UdpTransportTarget | Udp6TransportTarget self.request_args: RequestArgsType | None = None self.baseoid = baseoid self.last_results = [] self.success_init = False + @classmethod + async def create(cls, config): + """Asynchronously test the target device before fully initializing the scanner.""" + host = config[CONF_HOST] + + try: + # Try IPv4 first. + target = await UdpTransportTarget.create( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError: + # Then try IPv6. + try: + target = Udp6TransportTarget( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError as err: + _LOGGER.error("Invalid SNMP host: %s", err) + return None + instance = cls(config) + instance._target = target + + return instance + async def async_init(self, hass: HomeAssistant) -> None: """Make a one-off read to check if the target device is reachable and readable.""" self.request_args = await async_create_request_cmd_args( - hass, self._auth_data, self._target, self.baseoid + hass, + self._auth_data, + self._target, + self.baseoid, ) data = await self.async_get_snmp_data() self.success_init = data is not None @@ -154,7 +168,7 @@ class SnmpScanner(DeviceScanner): assert self.request_args is not None engine, auth_data, target, context_data, object_type = self.request_args - walker = bulkWalkCmd( + walker = bulk_walk_cmd( engine, auth_data, target, @@ -177,7 +191,7 @@ class SnmpScanner(DeviceScanner): return None for _oid, value in res: - if not isEndOfMib(res): + if not is_end_of_mib(res): try: mac = binascii.hexlify(value.asOctets()).decode("utf-8") except AttributeError: diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index a2a4405a1b5..ebe1bcc0262 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], "quality_scale": "legacy", - "requirements": ["pysnmp==6.2.6"] + "requirements": ["pysnmp==7.1.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index bd50e2050e0..3574affaccd 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -8,13 +8,13 @@ from struct import unpack from pyasn1.codec.ber import decoder from pysnmp.error import PySnmpError -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - getCmd, + get_cmd, ) from pysnmp.proto.rfc1902 import Opaque from pysnmp.proto.rfc1905 import NoSuchObject @@ -134,7 +134,7 @@ async def async_setup_platform( try: # Try IPv4 first. - target = UdpTransportTarget((host, port), timeout=DEFAULT_TIMEOUT) + target = await UdpTransportTarget.create((host, port), timeout=DEFAULT_TIMEOUT) except PySnmpError: # Then try IPv6. try: @@ -159,7 +159,7 @@ async def async_setup_platform( auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid) - get_result = await getCmd(*request_args) + get_result = await get_cmd(*request_args) errindication, _, _, _ = get_result if errindication and not accept_errors: @@ -235,7 +235,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index fd405567d60..26fb7d5e99d 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -5,15 +5,15 @@ from __future__ import annotations import logging from typing import Any -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ObjectIdentity, ObjectType, UdpTransportTarget, UsmUserData, - getCmd, - setCmd, + get_cmd, + set_cmd, ) from pysnmp.proto.rfc1902 import ( Counter32, @@ -169,7 +169,7 @@ async def async_setup_platform( else: auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) - transport = UdpTransportTarget((host, port)) + transport = await UdpTransportTarget.create((host, port)) request_args = await async_create_request_cmd_args( hass, auth_data, transport, baseoid ) @@ -228,10 +228,17 @@ class SnmpSwitch(SwitchEntity): self._state: bool | None = None self._payload_on = payload_on self._payload_off = payload_off - self._target = UdpTransportTarget((host, port)) + self._host = host + self._port = port self._request_args = request_args self._command_args = command_args + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + # The transport creation is done once this entity is registered with HA + # (rather than in the __init__) + self._target = await UdpTransportTarget.create((self._host, self._port)) # pylint: disable=attribute-defined-outside-init + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" # If vartype set, use it - https://www.pysnmp.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType @@ -255,7 +262,7 @@ class SnmpSwitch(SwitchEntity): async def async_update(self) -> None: """Update the state.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication: @@ -291,6 +298,6 @@ class SnmpSwitch(SwitchEntity): async def _set(self, value: Any) -> None: """Set the state of the switch.""" - await setCmd( + await set_cmd( *self._command_args, ObjectType(ObjectIdentity(self._commandoid), value) ) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py index dd3e9a6b6d2..df0171b6610 100644 --- a/homeassistant/components/snmp/util.py +++ b/homeassistant/components/snmp/util.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ContextData, ObjectIdentity, @@ -14,8 +14,8 @@ from pysnmp.hlapi.asyncio import ( UdpTransportTarget, UsmUserData, ) -from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor -from pysnmp.smi.builder import MibBuilder +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD +from pysnmp.smi import view from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -80,7 +80,7 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: @callback def _async_shutdown_listener(ev: Event) -> None: _LOGGER.debug("Unconfiguring SNMP engine") - lcd.unconfigure(engine, None) + LCD.unconfigure(engine, None) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener) return engine @@ -89,10 +89,10 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: def _get_snmp_engine() -> SnmpEngine: """Return a cached instance of SnmpEngine.""" engine = SnmpEngine() - mib_controller = vbProcessor.getMibViewController(engine) - # Actually load the MIBs from disk so we do - # not do it in the event loop - builder: MibBuilder = mib_controller.mibBuilder - if "PYSNMP-MIB" not in builder.mibSymbols: - builder.loadModules() + # Actually load the MIBs from disk so we do not do it in the event loop + mib_view_controller = view.MibViewController( + engine.message_dispatcher.mib_instrum_controller.get_mib_builder() + ) + engine.cache["mibViewController"] = mib_view_controller + mib_view_controller.mibBuilder.load_modules() return engine diff --git a/requirements_all.txt b/requirements_all.txt index c1c783f3d8c..a0f903370b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -677,7 +677,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -2363,7 +2363,7 @@ pysml==0.1.5 pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd21c1d1f63..aee0dc556a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -604,7 +604,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -1966,7 +1966,7 @@ pysml==0.1.5 pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/tests/components/snmp/test_float_sensor.py b/tests/components/snmp/test_float_sensor.py index a4f6e21dad7..032a89e8be8 100644 --- a/tests/components/snmp/test_float_sensor.py +++ b/tests/components/snmp/test_float_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Opaque(value=b"\x9fx\x04=\xa4\x00\x00") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_init.py b/tests/components/snmp/test_init.py index 0aa97dcc475..37039444aa0 100644 --- a/tests/components/snmp/test_init.py +++ b/tests/components/snmp/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from pysnmp.hlapi.asyncio import SnmpEngine -from pysnmp.hlapi.asyncio.cmdgen import lcd +from pysnmp.hlapi.v3arch.asyncio import SnmpEngine +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD from homeassistant.components import snmp from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -16,7 +16,7 @@ async def test_async_get_snmp_engine(hass: HomeAssistant) -> None: assert isinstance(engine, SnmpEngine) engine2 = await snmp.async_get_snmp_engine(hass) assert engine is engine2 - with patch.object(lcd, "unconfigure") as mock_unconfigure: + with patch.object(LCD, "unconfigure") as mock_unconfigure: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert mock_unconfigure.called diff --git a/tests/components/snmp/test_integer_sensor.py b/tests/components/snmp/test_integer_sensor.py index 8e7e0f166ef..8a7d3b91138 100644 --- a/tests/components/snmp/test_integer_sensor.py +++ b/tests/components/snmp/test_integer_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_negative_sensor.py b/tests/components/snmp/test_negative_sensor.py index 66a111b68d0..512cd536df9 100644 --- a/tests/components/snmp/test_negative_sensor.py +++ b/tests/components/snmp/test_negative_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(-13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_string_sensor.py b/tests/components/snmp/test_string_sensor.py index 5362e79c98d..b51fae0afe5 100644 --- a/tests/components/snmp/test_string_sensor.py +++ b/tests/components/snmp/test_string_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = OctetString("98F") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_switch.py b/tests/components/snmp/test_switch.py index fe1c3922ff0..a70428934ac 100644 --- a/tests/components/snmp/test_switch.py +++ b/tests/components/snmp/test_switch.py @@ -27,7 +27,7 @@ async def test_snmp_integer_switch_off(hass: HomeAssistant) -> None: mock_data = Integer32(0) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -41,7 +41,7 @@ async def test_snmp_integer_switch_on(hass: HomeAssistant) -> None: mock_data = Integer32(1) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -57,7 +57,7 @@ async def test_snmp_integer_switch_unknown( mock_data = Integer32(3) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) From 334d5f09fb693343dff7094d018205ceb577dfdf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Jul 2025 11:24:00 +0200 Subject: [PATCH 0142/1118] Create Google Generative AI sub entries for an enabled entry (#148161) Co-authored-by: Erik Montnemery --- .../__init__.py | 121 ++++- .../config_flow.py | 2 +- .../test_init.py | 427 +++++++++++++++++- 3 files changed, 520 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a3b87c05e5a..1ff9f355c06 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -195,11 +195,15 @@ async def async_update_options( async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -213,9 +217,14 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) if use_existing: @@ -228,25 +237,51 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: unique_id=None, ), ) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -266,12 +301,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_TITLE, options={}, version=2, - minor_version=2, + minor_version=4, ) @@ -315,19 +351,58 @@ async def async_migrate_entry( if entry.version == 2 and entry.minor_version == 2: # Add AI Task subentry with default options - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index a68ca09e76d..7d1429b110e 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -97,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_api( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 351293e7ac0..e154f9d33c9 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch from google.genai.types import File, FileState @@ -17,11 +18,17 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) -from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData +from homeassistant.config_entries import ( + ConfigEntryDisabler, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID @@ -479,7 +486,7 @@ async def test_migration_from_v1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -556,6 +563,223 @@ async def test_migration_from_v1( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 4 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -633,7 +857,7 @@ async def test_migration_from_v1_with_multiple_keys( for entry in entries: assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -736,7 +960,7 @@ async def test_migration_from_v1_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -957,7 +1181,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -1094,7 +1318,7 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: # Check version and subversion were updated assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 # Check we now have conversation, tts and ai_task_data subentries assert len(entry.subentries) == 3 @@ -1123,3 +1347,194 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert tts_subentry is not None assert tts_subentry.title == DEFAULT_TTS_NAME assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation and TTS subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From ad4e5459b148918d2e2dd07ea7476a5e8a473751 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:25:22 +0200 Subject: [PATCH 0143/1118] Fix - only enable AlexaModeController if at least one mode is offered (#148614) --- homeassistant/components/alexa/entities.py | 23 ++- tests/components/alexa/test_entities.py | 165 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7088b624e21..5f789813869 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity): ): yield AlexaThermostatController(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity) - if self.entity.domain == water_heater.DOMAIN and ( - supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + if ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) ): yield AlexaModeController( self.entity, @@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity): self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) force_range_controller = False - if supported & fan.FanEntityFeature.PRESET_MODE: + if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( + fan.ATTR_PRESET_MODES + ): yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) @@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity): yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] - if activities and supported & remote.RemoteEntityFeature.ACTIVITY: + if ( + activities + and (supported & remote.RemoteEntityFeature.ACTIVITY) + and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + ): yield AlexaModeController( self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" ) @@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & humidifier.HumidifierEntityFeature.MODES: + if ( + supported & humidifier.HumidifierEntityFeature.MODES + ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): yield AlexaModeController( self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" ) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 6998b2acc97..4d8d0dca67f 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import fan, humidifier, remote, water_heater from homeassistant.components.alexa import smart_home from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant @@ -200,3 +201,167 @@ async def test_serialize_discovery_recovers( "Error serializing Alexa.PowerController discovery" f" for {hass.states.get('switch.bla')}" ) in caplog.text + + +@pytest.mark.parametrize( + ("domain", "state", "state_attributes", "mode_controller_exists"), + [ + ("switch", "on", {}, False), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": "eco", + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": [], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + False, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto", "manual"], + "mode": "auto", + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto"], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": [], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + False, + ), + ( + "remote", + "on", + { + "activity_list": ["tv", "dvd"], + "current_activity": "tv", + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": ["tv"], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": [], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + False, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on", "auto"], + "operation_mode": "auto", + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on"], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": [], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + False, + ), + ], +) +async def test_mode_controller_is_omitted_if_no_modes_are_set( + hass: HomeAssistant, + domain: str, + state: str, + state_attributes: dict[str, Any], + mode_controller_exists: bool, +) -> None: + """Test we do not generate an invalid discovery with AlexaModeController during serialize discovery. + + AlexModeControllers need at least 2 modes. If one mode is set, an extra mode will be added for compatibility. + If no modes are offered, the mode controller should be omitted to prevent schema validations. + """ + request = get_new_request("Alexa.Discovery", "Discover") + + hass.states.async_set( + f"{domain}.bla", state, {"friendly_name": "Boop Woz"} | state_attributes + ) + + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + msg = msg["event"] + + interfaces = { + ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"] + } + + assert ("Alexa.ModeController" in interfaces) is mode_controller_exists From 09104fca4d3647c6c69c6b93573096079d64b591 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:26:37 +0200 Subject: [PATCH 0144/1118] Fix hide empty sections in mqtt subentry flows (#148692) --- homeassistant/components/mqtt/config_flow.py | 3 ++ tests/components/mqtt/test_config_flow.py | 51 +++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ee451b5f81d..a3cf2d1d12f 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2114,6 +2114,9 @@ def data_schema_from_fields( if schema_section is None: data_schema.update(data_schema_element) continue + if not data_schema_element: + # Do not show empty sections + continue collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9386f1da32c..77c74001939 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3220,7 +3220,7 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process entity details setep + # Process entity details step assert result["step_id"] == "entity_platform_config" # First test validators if set of test @@ -4212,3 +4212,52 @@ async def test_subentry_reconfigure_availablity( "payload_available": "1", "payload_not_available": "0", } + + +async def test_subentry_configflow_section_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow sections are hidden when they have no configurable options.""" + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Bla", "mqtt_settings": {"qos": 1}}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"platform": "fan"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + + # Process entity details step + assert result["step_id"] == "entity_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"fan_feature_speed": True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "mqtt_platform_config" + + # Check mqtt platform config flow sections from data schema + data_schema = result["data_schema"].schema + assert "fan_speed_settings" in data_schema + assert "fan_preset_mode_settings" not in data_schema From 21b1122f83996bac4435834620f56930da90f1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20M=C3=A5rtensson?= Date: Mon, 14 Jul 2025 11:43:02 +0200 Subject: [PATCH 0145/1118] Add test fixture for Tuya cover (#148660) --- tests/components/tuya/__init__.py | 5 ++ .../am43_corded_motor_zigbee_cover.json | 61 +++++++++++++++++++ .../components/tuya/snapshots/test_cover.ambr | 51 ++++++++++++++++ .../tuya/snapshots/test_select.ambr | 57 +++++++++++++++++ tests/components/tuya/test_cover.py | 33 ++++++++++ 5 files changed, 207 insertions(+) create mode 100644 tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 80e21e84c2e..09606c7e116 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,6 +13,11 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "am43_corded_motor_zigbee_cover": [ + # https://github.com/home-assistant/core/issues/71242 + Platform.SELECT, + Platform.COVER, + ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 Platform.COVER, diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json new file mode 100644 index 00000000000..14d1c39fc94 --- /dev/null +++ b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json @@ -0,0 +1,61 @@ +{ + "id": "zah67ekd", + "name": "Kitchen Blinds", + "category": "cl", + "product_id": "zah67ekd", + "product_name": "AM43拉绳电机-Zigbee", + "online": true, + "function": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "percent_state": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + }, + "work_state": { + "type": "Enum", + "value": { "range": ["opening", "closing"] } + }, + "situation_set": { + "type": "Enum", + "value": { "range": ["fully_open", "fully_close"] } + }, + "fault": { + "type": "Bitmap", + "value": { "label": ["motor_fault"] } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "percent_state": 52, + "control_back_mode": "forward", + "work_state": "closing", + "situation_set": "fully_open", + "fault": 0 + } +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 843ee2db6b0..1ab635919ca 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-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.kitchen_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.zah67ekdcontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 48, + 'device_class': 'curtain', + 'friendly_name': 'Kitchen Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 519ac33fb9f..e8337fb4fbf 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_blinds_motor_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': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.zah67ekdcontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Blinds Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 6f94896c8c7..4550ed9d6f4 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -55,3 +55,36 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["am43_corded_motor_zigbee_cover"], +) +@pytest.mark.parametrize( + ("percent_control", "percent_state"), + [ + (100, 52), + (0, 100), + (50, 25), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_percent_state_on_cover( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + percent_control: int, + percent_state: int, +) -> None: + """Test percent_state attribute on the cover entity.""" + mock_device.status["percent_control"] = percent_control + # 100 is closed and 0 is open for Tuya covers + mock_device.status["percent_state"] = 100 - percent_state + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + cover_state = hass.states.get("cover.kitchen_blinds_curtain") + assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" + assert cover_state.attributes["current_position"] == percent_state From 50047f0a4e67de5285ddc270276d58f4f1923225 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:46:17 +0200 Subject: [PATCH 0146/1118] Add new device class for absolute humidity (#148567) --- homeassistant/components/number/const.py | 10 ++++++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 3 +++ homeassistant/components/sensor/device_trigger.py | 3 +++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 7 +++++-- tests/components/sensor/common.py | 2 ++ tests/components/sensor/test_device_condition.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/util/test_unit_conversion.py | 8 ++++++++ 14 files changed, 62 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1b41146cd2a..bfb74d621c3 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -78,6 +79,11 @@ class NumberDeviceClass(StrEnum): """Device class for numbers.""" # NumberDeviceClass should be aligned with SensorDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ APPARENT_POWER = "apparent_power" """Apparent power. @@ -452,6 +458,10 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { + NumberDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, NumberDeviceClass.AREA: set(UnitOfArea), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index dcce09984bd..482b4bc6793 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:ray-vertex" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 998b9ffba38..1e4290f1d75 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -31,6 +31,9 @@ } } }, + "absolute_humidity": { + "name": "[%key:component::sensor::entity_component::absolute_humidity::name%]" + }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" }, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 994c29b6bbf..5f9d5ec9ca0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -107,6 +108,12 @@ class SensorDeviceClass(StrEnum): """ # Numerical device classes, these should be aligned with NumberDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + APPARENT_POWER = "apparent_power" """Apparent power. @@ -521,6 +528,7 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, @@ -554,6 +562,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = } DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, SensorDeviceClass.AREA: set(UnitOfArea), @@ -651,6 +663,7 @@ DEFAULT_PRECISION_LIMIT = 2 # have 0 decimals, that one should be used and not mW, even though mW also should have # 0 decimals. Otherwise the smaller units will have more decimals than expected. UNITS_PRECISION = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1), SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0), SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0), @@ -691,6 +704,7 @@ UNITS_PRECISION = { } DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AREA: set(SensorStateClass), diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 2b1eb350c3e..1ad5fe12e99 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -33,6 +33,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_IS_ABSOLUTE_HUMIDITY = "is_absolute_humidity" CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" CONF_IS_AREA = "is_area" @@ -88,6 +89,7 @@ CONF_IS_WIND_DIRECTION = "is_wind_direction" CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_IS_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], @@ -159,6 +161,7 @@ CONDITION_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_IS_ABSOLUTE_HUMIDITY, CONF_IS_APPARENT_POWER, CONF_IS_AQI, CONF_IS_AREA, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d44611a49db..ae2125962e8 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -32,6 +32,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_ABSOLUTE_HUMIDITY = "absolute_humidity" CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" CONF_AREA = "area" @@ -87,6 +88,7 @@ CONF_WIND_DIRECTION = "wind_direction" CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], @@ -159,6 +161,7 @@ TRIGGER_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_ABSOLUTE_HUMIDITY, CONF_APPARENT_POWER, CONF_AQI, CONF_AREA, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 05311868fc6..cea955e061c 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:eye" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ecaeb2504d9..c69bf99eff0 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -2,6 +2,7 @@ "title": "Sensor", "device_automation": { "condition_type": { + "is_absolute_humidity": "Current {entity_name} absolute humidity", "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", "is_area": "Current {entity_name} area", @@ -57,6 +58,7 @@ "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { + "absolute_humidity": "{entity_name} absolute humidity changes", "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", "area": "{entity_name} area changes", @@ -148,6 +150,9 @@ "duration": { "name": "Duration" }, + "absolute_humidity": { + "name": "Absolute humidity" + }, "apparent_power": { "name": "Apparent power" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index e6da8ba4a69..6b4f16c316f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -910,6 +910,7 @@ class UnitOfPrecipitationDepth(StrEnum): # Concentration units +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d0830d1f8bb..5bde108dfc1 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -7,6 +7,7 @@ from functools import lru_cache from math import floor, log10 from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -693,12 +694,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "concentration" _UNIT_CONVERSION: dict[str | None, float] = { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000.0, # 1000 µg/m³ = 1 mg/m³ - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.0, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³ + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³ + CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0, } VALID_UNITS = { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_GRAMS_PER_CUBIC_METER, } diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 2df13b697da..1b9810a8250 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.components.sensor.const import DEVICE_CLASS_STATE_CLASSES from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -44,6 +45,7 @@ from homeassistant.const import ( from tests.common import MockEntity UNITS_OF_MEASUREMENT = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: CONCENTRATION_GRAMS_PER_CUBIC_METER, SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, SensorDeviceClass.AQI: None, SensorDeviceClass.AREA: UnitOfArea.SQUARE_METERS, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 1c87845c2c7..da69610f4c5 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -125,7 +125,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 54 + assert len(conditions) == 55 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index bb57797e6dd..c39a5216f0f 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -126,7 +126,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 54 + assert len(triggers) == 55 assert triggers == unordered(expected_triggers) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 7d0eb7226a0..537cfb33c31 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -8,6 +8,7 @@ from itertools import chain import pytest from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -762,6 +763,13 @@ _CONVERTED_VALUE: dict[ 2000, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), + # 3 g/m³ = 3000 mg/m³ + ( + 3, + CONCENTRATION_GRAMS_PER_CUBIC_METER, + 3000, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), ], VolumeConverter: [ (5, UnitOfVolume.LITERS, 1.32086, UnitOfVolume.GALLONS), From dcbdce4b2b0611439c82e2e8d8cd4743174a682f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 11:57:27 +0200 Subject: [PATCH 0147/1118] Improve docstrings of event helpers related to state changes (#148722) --- homeassistant/helpers/event.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3b959337b6d..f2dfb7250f7 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -316,6 +316,10 @@ def async_track_state_change_event( Unlike async_track_state_change, async_track_state_change_event passes the full event to the callback. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + In order to avoid having to iterate a long list of EVENT_STATE_CHANGED and fire and create a job for each one, we keep a dict of entity ids that @@ -866,6 +870,10 @@ def async_track_state_change_filtered( ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + Args: hass: Home assistant object. @@ -1348,9 +1356,13 @@ def async_track_template_result( then whenever the output from the template changes. The template will be reevaluated if any states referenced in the last run of the template change, or if manually triggered. If the result of the - evaluation is different from the previous run, the listener is passed + evaluation is different from the previous run, the action is passed the result. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + If the template results in an TemplateError, this will be returned to the listener the first time this happens but not for subsequent errors. Once the template returns to a non-error condition the result is sent From 2b7992e849217ef85d272542c951d837d24d0c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Beye?= Date: Sat, 5 Jul 2025 11:36:45 +0200 Subject: [PATCH 0148/1118] Squeezebox: Fix track selection in media browser (#147185) --- homeassistant/components/squeezebox/browse_media.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 03df289a2fd..f09c2e3f2a7 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -311,8 +311,7 @@ async def build_item_response( title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], - can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] - is not None, + can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]), can_play=True, ) From 9077965214402a69d4a1fb5d7c66dbb2562e5738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Beye?= Date: Sat, 5 Jul 2025 11:31:30 +0200 Subject: [PATCH 0149/1118] Squeezebox: Fix tracks not having thumbnails (#147187) --- homeassistant/components/squeezebox/browse_media.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index f09c2e3f2a7..bab4f90c6d1 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -221,12 +221,16 @@ def _get_item_thumbnail( ) -> str | None: """Construct path to thumbnail image.""" item_thumbnail: str | None = None - if artwork_track_id := item.get("artwork_track_id"): + track_id = item.get("artwork_track_id") or ( + item.get("id") if item_type == "track" else None + ) + + if track_id: if internal_request: - item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + item_thumbnail = player.generate_image_url_from_track_id(track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item["id"], artwork_track_id + item_type, item["id"], track_id ) elif search_type in ["apps", "radios"]: From c965da655924dfc987df5f2091bb4f1fa97662b7 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 5 Jul 2025 06:35:44 +1000 Subject: [PATCH 0150/1118] Bump pysmlight to v0.2.7 (#148101) Co-authored-by: Franck Nijhof --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 9a37cc554c7..9340573f6ce 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.6"], + "requirements": ["pysmlight==0.2.7"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index fcd97e305fc..3a612431369 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2360,7 +2360,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a234822689c..e284b9dbb18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1963,7 +1963,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp pysnmp==6.2.6 From 25f64a2f3698b26023bd477efda5f223f7425306 Mon Sep 17 00:00:00 2001 From: ekutner <5628151+ekutner@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:11:36 +0300 Subject: [PATCH 0151/1118] Do not specify the code_format when a code is not required (#148698) --- .../components/risco/alarm_control_panel.py | 10 +- .../risco/test_alarm_control_panel.py | 147 +++++++++++++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 2472baa932e..f485c923776 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -82,7 +82,6 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" - _attr_code_format = CodeFormat.NUMBER _attr_has_entity_name = True _attr_name = None @@ -100,8 +99,13 @@ class RiscoAlarm(AlarmControlPanelEntity): self._partition_id = partition_id self._partition = partition self._code = code - self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] - self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] + arm_required = options[CONF_CODE_ARM_REQUIRED] + disarm_required = options[CONF_CODE_DISARM_REQUIRED] + self._attr_code_arm_required = arm_required + self._code_disarm_required = disarm_required + self._attr_code_format = ( + CodeFormat.NUMBER if arm_required or disarm_required else None + ) self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] for state in self._ha_to_risco: diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 8caef1fbfc4..d27d39071a0 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -35,6 +35,7 @@ FIRST_LOCAL_ENTITY_ID = "alarm_control_panel.name_0" SECOND_LOCAL_ENTITY_ID = "alarm_control_panel.name_1" CODES_REQUIRED_OPTIONS = {"code_arm_required": True, "code_disarm_required": True} +CODES_NOT_REQUIRED_OPTIONS = {"code_arm_required": False, "code_disarm_required": False} TEST_RISCO_TO_HA = { "arm": AlarmControlPanelState.ARMED_AWAY, "partial_arm": AlarmControlPanelState.ARMED_HOME, @@ -388,7 +389,8 @@ async def test_cloud_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_correct_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -452,7 +454,58 @@ async def test_cloud_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_cloud_sets_without_code( + hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud +) -> None: + """Test settings the various modes when code is not required.""" + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_CLOUD_ENTITY_ID, 0, "C" + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_CLOUD_ENTITY_ID, 1, "C" + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_CLOUD_ENTITY_ID, + 0, + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_CLOUD_ENTITY_ID, + 1, + ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_incorrect_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -837,7 +890,8 @@ async def test_local_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_correct_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -931,7 +985,8 @@ async def test_local_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_incorrect_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -1020,3 +1075,87 @@ async def test_local_sets_with_incorrect_code( two_part_local_alarm[1], **code, ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_local_sets_without_code( + hass: HomeAssistant, two_part_local_alarm, setup_risco_local +) -> None: + """Test settings the various modes when code is not required.""" + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + "C", + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + "C", + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) From 9650727515df49c51a193ffd71a255d7db2bc4ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 08:32:58 -0500 Subject: [PATCH 0152/1118] Fix REST sensor charset handling to respect Content-Type header (#148223) --- homeassistant/components/rest/data.py | 9 ++- tests/components/rest/test_sensor.py | 88 +++++++++++++++++++++++++++ tests/test_util/aiohttp.py | 21 ++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 731d1ffe9c3..3ce70f00c0d 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -140,7 +140,14 @@ class RestData: self._method, self._resource, **request_kwargs ) as response: # Read the response - self.data = await response.text(encoding=self._encoding) + # Only use configured encoding if no charset in Content-Type header + # If charset is present in Content-Type, let aiohttp use it + if response.charset: + # Let aiohttp use the charset from Content-Type header + self.data = await response.text() + else: + # Use configured encoding as fallback + self.data = await response.text(encoding=self._encoding) self.headers = response.headers except TimeoutError as ex: diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index c688ff1b314..cbc77e9d53f 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -162,6 +162,94 @@ async def test_setup_encoding( assert hass.states.get("sensor.mysensor").state == "tack själv" +async def test_setup_auto_encoding_from_content_type( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with encoding auto-detected from Content-Type header.""" + # Test with ISO-8859-1 charset in Content-Type header + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + content="Björk Guðmundsdóttir".encode("iso-8859-1"), + headers={"Content-Type": "text/plain; charset=iso-8859-1"}, + ) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "name": "mysensor", + # encoding defaults to UTF-8, but should be ignored when charset present + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" + + +async def test_setup_encoding_fallback_no_charset( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that configured encoding is used when no charset in Content-Type.""" + # No charset in Content-Type header + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + content="Björk Guðmundsdóttir".encode("iso-8859-1"), + headers={"Content-Type": "text/plain"}, # No charset! + ) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "name": "mysensor", + "encoding": "iso-8859-1", # This will be used as fallback + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" + + +async def test_setup_charset_overrides_encoding_config( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that charset in Content-Type overrides configured encoding.""" + # Server sends UTF-8 with correct charset header + aioclient_mock.get( + "http://localhost", + status=HTTPStatus.OK, + content="Björk Guðmundsdóttir".encode(), + headers={"Content-Type": "text/plain; charset=utf-8"}, + ) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "name": "mysensor", + "encoding": "iso-8859-1", # Config says ISO-8859-1, but charset=utf-8 should win + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + # This should work because charset=utf-8 overrides the iso-8859-1 config + assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" + + @pytest.mark.parametrize( ("ssl_cipher_list", "ssl_cipher_list_expected"), [ diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index eea3f4e88b4..a3c714e1f62 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -191,7 +191,6 @@ class AiohttpClientMockResponse: if response is None: response = b"" - self.charset = "utf-8" self.method = method self._url = url self.status = status @@ -261,16 +260,32 @@ class AiohttpClientMockResponse: """Return content.""" return mock_stream(self.response) + @property + def charset(self): + """Return charset from Content-Type header.""" + if (content_type := self._headers.get("content-type")) is None: + return None + content_type = content_type.lower() + if "charset=" in content_type: + return content_type.split("charset=")[1].split(";")[0].strip() + return None + async def read(self): """Return mock response.""" return self.response - async def text(self, encoding="utf-8", errors="strict"): + async def text(self, encoding=None, errors="strict") -> str: """Return mock response as a string.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return self.response.decode(encoding, errors=errors) - async def json(self, encoding="utf-8", content_type=None, loads=json_loads): + async def json(self, encoding=None, content_type=None, loads=json_loads) -> Any: """Return mock response as a json.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return loads(self.response.decode(encoding)) def release(self): From 74f9549431fb8ae906f1074a51affba15af07b45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jul 2025 12:40:19 -0500 Subject: [PATCH 0153/1118] Fix UTF-8 encoding for REST basic authentication (#148225) --- homeassistant/components/rest/data.py | 2 +- tests/components/rest/test_binary_sensor.py | 33 +++++++++++++++++++++ tests/test_util/aiohttp.py | 3 ++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3ce70f00c0d..7f346277314 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -49,7 +49,7 @@ class RestData: # Convert auth tuple to aiohttp.BasicAuth if needed if isinstance(auth, tuple) and len(auth) == 2: self._auth: aiohttp.BasicAuth | aiohttp.DigestAuthMiddleware | None = ( - aiohttp.BasicAuth(auth[0], auth[1]) + aiohttp.BasicAuth(auth[0], auth[1], encoding="utf-8") ) else: self._auth = auth diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 315f8113309..af7503a7007 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -667,3 +667,36 @@ async def test_availability_blocks_value_template( await hass.async_block_till_done() assert error in caplog.text + + +async def test_setup_get_basic_auth_utf8( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with basic auth using UTF-8 characters including Unicode char \u2018.""" + # Use a password with the Unicode character \u2018 (left single quotation mark) + aioclient_mock.get("http://localhost", status=HTTPStatus.OK, json={"key": "on"}) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "test_user", + "password": "test\u2018password", # Password with Unicode char + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index a3c714e1f62..c3a8be77b77 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -156,6 +156,9 @@ class AiohttpClientMocker: for response in self._mocks: if response.match_request(method, url, params): + # If auth is provided, try to encode it to trigger any encoding errors + if auth is not None: + auth.encode() self.mock_calls.append((method, url, data, headers)) if response.side_effect: response = await response.side_effect(method, url, data) From f4ca56052b575e161fef85cfd5dc9fd016b49d3a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 5 Jul 2025 23:34:23 +0200 Subject: [PATCH 0154/1118] Bump pylamarzocco to 2.0.10 (#148233) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 7fdafc4dda1..10cb23146ae 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.9"] + "requirements": ["pylamarzocco==2.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a612431369..bac56ca7d70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2100,7 +2100,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.10 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e284b9dbb18..107b8d21703 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.10 # homeassistant.components.lastfm pylast==5.1.0 From b4d789f8e2df2515c7f02cdd9349314d972638bc Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Mon, 7 Jul 2025 09:18:15 -0400 Subject: [PATCH 0155/1118] Bump sharkiq to 1.1.1 (#148244) --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 9f9009693e5..c29fc582462 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.1.0"] + "requirements": ["sharkiq==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bac56ca7d70..3bc9d412cc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2756,7 +2756,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 107b8d21703..5c9ae5c6235 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2278,7 +2278,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.11 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.simplefin simplefin4py==0.0.18 From 59bcf1167a14b5e4c1d98dbbc7433aea948792c6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 6 Jul 2025 12:38:57 +0200 Subject: [PATCH 0156/1118] bump motionblinds to 0.6.29 (#148265) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index a82da20396f..eca520d8946 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.28"] + "requirements": ["motionblinds==0.6.29"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3bc9d412cc5..c886f94a68a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1452,7 +1452,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.29 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c9ae5c6235..51510f46a33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,7 +1244,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.29 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 5c3b279f958349200f9265ebaab773ad5f160600 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 7 Jul 2025 12:37:39 +0300 Subject: [PATCH 0157/1118] Bump aiowebostv to 0.7.4 (#148273) --- .../components/webostv/config_flow.py | 5 +++- .../components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/test_config_flow.py | 30 ++++++++++++++++++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 80c8fb7f8f2..2af38cb3d17 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -98,7 +98,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" + if model_name := client.tv_info.system.get("modelName"): + self._name = f"{DEFAULT_NAME} {model_name}" + else: + self._name = DEFAULT_NAME return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 8ac470ae922..c3c3e9a564f 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.3"], + "requirements": ["aiowebostv==0.7.4"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index c886f94a68a..7458c0f94e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -435,7 +435,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.4 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51510f46a33..13a04de9064 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.4 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 564ff9afa9b..2445140aff4 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -4,7 +4,12 @@ from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID +from homeassistant.components.webostv.const import ( + CONF_SOURCES, + DEFAULT_NAME, + DOMAIN, + LIVE_TV_APP_ID, +) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -63,6 +68,29 @@ async def test_form(hass: HomeAssistant, client) -> None: assert config_entry.unique_id == FAKE_UUID +async def test_form_no_model_name(hass: HomeAssistant, client) -> None: + """Test successful user flow without model name.""" + client.tv_info.system = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_USER_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + + @pytest.mark.parametrize( ("apps", "inputs"), [ From 3d3f2527cb5d96b919e21a8db458d9428bdc5bae Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 7 Jul 2025 11:43:39 +0200 Subject: [PATCH 0158/1118] Bump `gios` to version 6.1.0 (#148274) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/gios/__init__.py | 33 +++++-- tests/components/gios/fixtures/indexes.json | 63 +++++++----- tests/components/gios/fixtures/sensors.json | 56 +++++------ tests/components/gios/fixtures/station.json | 98 ++++++++----------- .../gios/snapshots/test_diagnostics.ambr | 2 + 8 files changed, 134 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 8deb2eee414..ba87890de03 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.0.0"] + "requirements": ["gios==6.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7458c0f94e6..8c4850e3162 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13a04de9064..c75aca5c8e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.0 # homeassistant.components.glances glances-api==0.8.0 diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 49388428805..a4dc0a39be6 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -1,16 +1,29 @@ """Tests for GIOS.""" -import json from unittest.mock import patch from homeassistant.components.gios.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) STATIONS = [ - {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"}, - {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"}, + { + "Identyfikator stacji": 123, + "Nazwa stacji": "Test Name 1", + "WGS84 φ N": "99.99", + "WGS84 λ E": "88.88", + }, + { + "Identyfikator stacji": 321, + "Nazwa stacji": "Test Name 2", + "WGS84 φ N": "77.77", + "WGS84 λ E": "66.66", + }, ] @@ -26,13 +39,13 @@ async def init_integration( entry_id="86129426118ae32020417a53712d6eef", ) - indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN)) - station = json.loads(await async_load_fixture(hass, "station.json", DOMAIN)) - sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN)) + indexes = await async_load_json_object_fixture(hass, "indexes.json", DOMAIN) + station = await async_load_json_array_fixture(hass, "station.json", DOMAIN) + sensors = await async_load_json_object_fixture(hass, "sensors.json", DOMAIN) if incomplete_data: - indexes["stIndexLevel"]["indexLevelName"] = "foo" - sensors["pm10"]["values"][0]["value"] = None - sensors["pm10"]["values"][1]["value"] = None + indexes["AqIndex"] = "foo" + sensors["pm10"]["Lista danych pomiarowych"][0]["Wartość"] = None + sensors["pm10"]["Lista danych pomiarowych"][1]["Wartość"] = None if invalid_indexes: indexes = {} diff --git a/tests/components/gios/fixtures/indexes.json b/tests/components/gios/fixtures/indexes.json index c53d1c78f6e..1fb46e9a4d8 100644 --- a/tests/components/gios/fixtures/indexes.json +++ b/tests/components/gios/fixtures/indexes.json @@ -1,29 +1,38 @@ { - "id": 123, - "stCalcDate": "2020-07-31 15:10:17", - "stIndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "stSourceDataDate": "2020-07-31 14:00:00", - "so2CalcDate": "2020-07-31 15:10:17", - "so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "so2SourceDataDate": "2020-07-31 14:00:00", - "no2CalcDate": 1596201017000, - "no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "no2SourceDataDate": "2020-07-31 14:00:00", - "coCalcDate": "2020-07-31 15:10:17", - "coIndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "coSourceDataDate": "2020-07-31 14:00:00", - "pm10CalcDate": "2020-07-31 15:10:17", - "pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm10SourceDataDate": "2020-07-31 14:00:00", - "pm25CalcDate": "2020-07-31 15:10:17", - "pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm25SourceDataDate": "2020-07-31 14:00:00", - "o3CalcDate": "2020-07-31 15:10:17", - "o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "o3SourceDataDate": "2020-07-31 14:00:00", - "c6h6CalcDate": "2020-07-31 15:10:17", - "c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "c6h6SourceDataDate": "2020-07-31 14:00:00", - "stIndexStatus": true, - "stIndexCrParam": "OZON" + "AqIndex": { + "Identyfikator stacji pomiarowej": 123, + "Data wykonania obliczeń indeksu": "2020-07-31 15:10:17", + "Nazwa kategorii indeksu": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika st": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika SO2": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika SO2": 0, + "Nazwa kategorii indeksu dla wskażnika SO2": "Bardzo dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika SO2": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Wartość indeksu dla wskaźnika NO2": 0, + "Nazwa kategorii indeksu dla wskażnika NO2": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika CO": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika CO": 0, + "Nazwa kategorii indeksu dla wskażnika CO": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika CO": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM10": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM10": 0, + "Nazwa kategorii indeksu dla wskażnika PM10": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM10": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM2.5": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM2.5": 0, + "Nazwa kategorii indeksu dla wskażnika PM2.5": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM2.5": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika O3": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika O3": 1, + "Nazwa kategorii indeksu dla wskażnika O3": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika O3": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika C6H6": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika C6H6": 0, + "Nazwa kategorii indeksu dla wskażnika C6H6": "Bardzo dobry", + "Data wykonania obliczeń indeksu dla wskaźnika C6H6": "2020-07-31 14:00:00", + "Status indeksu ogólnego dla stacji pomiarowej": true, + "Kod zanieczyszczenia krytycznego": "OZON" + } } diff --git a/tests/components/gios/fixtures/sensors.json b/tests/components/gios/fixtures/sensors.json index db0cf2ff849..0fe387d3126 100644 --- a/tests/components/gios/fixtures/sensors.json +++ b/tests/components/gios/fixtures/sensors.json @@ -1,51 +1,51 @@ { "so2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4.35478 }, - { "date": "2020-07-31 14:00:00", "value": 4.25478 }, - { "date": "2020-07-31 13:00:00", "value": 4.34309 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4.35478 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.25478 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.34309 } ] }, "c6h6": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 0.23789 }, - { "date": "2020-07-31 14:00:00", "value": 0.22789 }, - { "date": "2020-07-31 13:00:00", "value": 0.21315 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 0.23789 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 0.22789 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 0.21315 } ] }, "co": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 251.874 }, - { "date": "2020-07-31 14:00:00", "value": 250.874 }, - { "date": "2020-07-31 13:00:00", "value": 251.097 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 251.874 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 250.874 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 251.097 } ] }, "no2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 7.13411 }, - { "date": "2020-07-31 14:00:00", "value": 7.33411 }, - { "date": "2020-07-31 13:00:00", "value": 9.32578 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 7.33411 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 } ] }, "o3": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 95.7768 }, - { "date": "2020-07-31 14:00:00", "value": 93.7768 }, - { "date": "2020-07-31 13:00:00", "value": 89.4232 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 93.7768 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 89.4232 } ] }, "pm2.5": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4 }, - { "date": "2020-07-31 14:00:00", "value": 4 }, - { "date": "2020-07-31 13:00:00", "value": 5 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5 } ] }, "pm10": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 16.8344 }, - { "date": "2020-07-31 14:00:00", "value": 17.8344 }, - { "date": "2020-07-31 13:00:00", "value": 20.8094 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 16.8344 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 17.8344 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 20.8094 } ] } } diff --git a/tests/components/gios/fixtures/station.json b/tests/components/gios/fixtures/station.json index 16cd824a489..167e4db3aee 100644 --- a/tests/components/gios/fixtures/station.json +++ b/tests/components/gios/fixtures/station.json @@ -1,72 +1,58 @@ [ { - "id": 672, - "stationId": 117, - "param": { - "paramName": "dwutlenek siarki", - "paramFormula": "SO2", - "paramCode": "SO2", - "idParam": 1 - } + "Identyfikator stanowiska": 672, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek siarki", + "Wskaźnik - wzór": "SO2", + "Wskaźnik - kod": "SO2", + "Id wskaźnika": 1 }, { - "id": 658, - "stationId": 117, - "param": { - "paramName": "benzen", - "paramFormula": "C6H6", - "paramCode": "C6H6", - "idParam": 10 - } + "Identyfikator stanowiska": 658, + "Identyfikator stacji": 117, + "Wskaźnik": "benzen", + "Wskaźnik - wzór": "C6H6", + "Wskaźnik - kod": "C6H6", + "Id wskaźnika": 10 }, { - "id": 660, - "stationId": 117, - "param": { - "paramName": "tlenek węgla", - "paramFormula": "CO", - "paramCode": "CO", - "idParam": 8 - } + "Identyfikator stanowiska": 660, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek węgla", + "Wskaźnik - wzór": "CO", + "Wskaźnik - kod": "CO", + "Id wskaźnika": 8 }, { - "id": 665, - "stationId": 117, - "param": { - "paramName": "dwutlenek azotu", - "paramFormula": "NO2", - "paramCode": "NO2", - "idParam": 6 - } + "Identyfikator stanowiska": 665, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek azotu", + "Wskaźnik - wzór": "NO2", + "Wskaźnik - kod": "NO2", + "Id wskaźnika": 6 }, { - "id": 667, - "stationId": 117, - "param": { - "paramName": "ozon", - "paramFormula": "O3", - "paramCode": "O3", - "idParam": 5 - } + "Identyfikator stanowiska": 667, + "Identyfikator stacji": 117, + "Wskaźnik": "ozon", + "Wskaźnik - wzór": "O3", + "Wskaźnik - kod": "O3", + "Id wskaźnika": 5 }, { - "id": 670, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM2.5", - "paramFormula": "PM2.5", - "paramCode": "PM2.5", - "idParam": 69 - } + "Identyfikator stanowiska": 670, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM2.5", + "Wskaźnik - wzór": "PM2.5", + "Wskaźnik - kod": "PM2.5", + "Id wskaźnika": 69 }, { - "id": 14395, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM10", - "paramFormula": "PM10", - "paramCode": "PM10", - "idParam": 3 - } + "Identyfikator stanowiska": 14395, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM10", + "Wskaźnik - wzór": "PM10", + "Wskaźnik - kod": "PM10", + "Id wskaźnika": 3 } ] diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 890edc00482..4095bf8bf53 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -42,12 +42,14 @@ 'name': 'carbon monoxide', 'value': 251.874, }), + 'no': None, 'no2': dict({ 'id': 665, 'index': 'good', 'name': 'nitrogen dioxide', 'value': 7.13411, }), + 'nox': None, 'o3': dict({ 'id': 667, 'index': 'good', From 672ffa598420c1c4f3d11fde393995982a6ad222 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 08:01:48 -0500 Subject: [PATCH 0159/1118] Restore httpx compatibility for non-primitive REST query parameters (#148286) --- homeassistant/components/rest/data.py | 10 ++ tests/components/rest/test_sensor.py | 127 ++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 7f346277314..3341f296fb9 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -115,6 +115,16 @@ class RestData: for key, value in rendered_params.items(): if isinstance(value, bool): rendered_params[key] = str(value).lower() + elif not isinstance(value, (str, int, float, type(None))): + # For backward compatibility with httpx behavior, convert non-primitive + # types to strings. This maintains compatibility after switching from + # httpx to aiohttp. See https://github.com/home-assistant/core/issues/148153 + _LOGGER.debug( + "REST query parameter '%s' has type %s, converting to string", + key, + type(value).__name__, + ) + rendered_params[key] = str(value) _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index cbc77e9d53f..b830d6b7743 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the REST sensor platform.""" from http import HTTPStatus +import logging import ssl from unittest.mock import patch @@ -19,6 +20,14 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_METHOD, + CONF_NAME, + CONF_PARAMS, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, CONTENT_TYPE_JSON, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -1066,6 +1075,124 @@ async def test_update_with_failed_get( assert "Empty reply" in caplog.text +async def test_query_param_dict_value( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test dict values in query params are handled for backward compatibility.""" + # Mock response + aioclient_mock.post( + "https://www.envertecportal.com/ApiInverters/QueryTerminalReal", + status=HTTPStatus.OK, + json={"Data": {"QueryResults": [{"POWER": 1500}]}}, + ) + + # This test checks that when template_complex processes a string that looks like + # a dict/list, it converts it to an actual dict/list, which then needs to be + # handled by our backward compatibility code + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.rest.data"): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: ( + "https://www.envertecportal.com/ApiInverters/" + "QueryTerminalReal" + ), + CONF_METHOD: "POST", + CONF_PARAMS: { + "page": "1", + "perPage": "20", + "orderBy": "SN", + # When processed by template.render_complex, certain + # strings might be converted to dicts/lists if they + # look like JSON + "whereCondition": ( + "{{ {'STATIONID': 'A6327A17797C1234'} }}" + ), # Template that evaluates to dict + }, + "sensor": [ + { + CONF_NAME: "Solar MPPT1 Power", + CONF_VALUE_TEMPLATE: ( + "{{ value_json.Data.QueryResults[0].POWER }}" + ), + CONF_DEVICE_CLASS: "power", + CONF_UNIT_OF_MEASUREMENT: "W", + CONF_FORCE_UPDATE: True, + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # The sensor should be created successfully with backward compatibility + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.solar_mppt1_power") + assert state is not None + assert state.state == "1500" + + # Check that a debug message was logged about the parameter conversion + assert "REST query parameter 'whereCondition' has type" in caplog.text + assert "converting to string" in caplog.text + + +async def test_query_param_json_string_preserved( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that JSON strings in query params are preserved and not converted to dicts.""" + # Mock response + aioclient_mock.get( + "https://api.example.com/data", + status=HTTPStatus.OK, + json={"value": 42}, + ) + + # Config with JSON string (quoted) - should remain a string + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: "https://api.example.com/data", + CONF_METHOD: "GET", + CONF_PARAMS: { + "filter": '{"type": "sensor", "id": 123}', # JSON string + "normal": "value", + }, + "sensor": [ + { + CONF_NAME: "Test Sensor", + CONF_VALUE_TEMPLATE: "{{ value_json.value }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # Check the sensor was created + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "42" + + # Verify the request was made with the JSON string intact + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + assert url.query["filter"] == '{"type": "sensor", "id": 123}' + assert url.query["normal"] == "value" + + async def test_reload(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Verify we can reload reset sensors.""" From 4a10370932b1ec01274353f5e817aad328fcf39e Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:40:48 +0200 Subject: [PATCH 0160/1118] Bump pyenphase to 2.2.1 (#148292) --- 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 8387ecc9c9f..278045001fc 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.0"], + "requirements": ["pyenphase==2.2.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 8c4850e3162..a45d470b0a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.0 +pyenphase==2.2.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c75aca5c8e4..1c61f8c7811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.0 +pyenphase==2.2.1 # homeassistant.components.everlights pyeverlights==0.1.0 From 14f059c766883246a174ceba98688951f9df7a35 Mon Sep 17 00:00:00 2001 From: jvits227 <133175738+jvits227@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:51:09 -0400 Subject: [PATCH 0161/1118] Add lamp states to smartthings selector (#148302) Co-authored-by: Joostlek --- homeassistant/components/smartthings/select.py | 5 +++++ tests/components/smartthings/snapshots/test_select.ambr | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 99dc7a09f87..3106aba5e49 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -18,6 +18,11 @@ from .entity import SmartThingsEntity LAMP_TO_HA = { "extraHigh": "extra_high", + "high": "high", + "mid": "mid", + "low": "low", + "on": "on", + "off": "off", } WASHER_SOIL_LEVEL_TO_HA = { diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 7dd57e89c6a..de4587b7ca8 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] @@ -112,7 +112,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'high', }) # --- # name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] From d303a7d17e95f2ca5036068ae602c29312937a0a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:57:30 +0800 Subject: [PATCH 0162/1118] Fix Switchbot cloud plug mini current unit Issue (#148314) --- homeassistant/components/switchbot_cloud/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 5a424ea7892..f93df234289 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -113,11 +113,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Plug Mini (US)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Plug Mini (JP)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, From 186c4e7038fdff9e5c84d687b2326de61ab62931 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:26:23 +0800 Subject: [PATCH 0163/1118] Bump pyswitchbot to 0.68.1 (#148335) --- 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 8e727425a2a..5ef7eec9976 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.67.0"] + "requirements": ["PySwitchbot==0.68.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a45d470b0a6..b5777776929 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.67.0 +PySwitchbot==0.68.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c61f8c7811..0496d7bdd9a 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.67.0 +PySwitchbot==0.68.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 2377b136f32570d5be551fb5aff94898b64444f1 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 13:05:16 +0200 Subject: [PATCH 0164/1118] Handle binary coils with non default mappings in nibe heatpump (#148354) --- .../components/nibe_heatpump/binary_sensor.py | 3 +- .../components/nibe_heatpump/switch.py | 8 +- tests/components/nibe_heatpump/__init__.py | 6 +- .../snapshots/test_binary_sensor.ambr | 97 +++++++++ .../nibe_heatpump/snapshots/test_switch.ambr | 193 ++++++++++++++++++ .../nibe_heatpump/test_binary_sensor.py | 49 +++++ tests/components/nibe_heatpump/test_button.py | 2 +- .../components/nibe_heatpump/test_climate.py | 2 +- tests/components/nibe_heatpump/test_number.py | 2 +- tests/components/nibe_heatpump/test_switch.py | 133 ++++++++++++ 10 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/nibe_heatpump/snapshots/test_switch.ambr create mode 100644 tests/components/nibe_heatpump/test_binary_sensor.py create mode 100644 tests/components/nibe_heatpump/test_switch.py diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 284e4d83569..d49862180bd 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -39,6 +39,7 @@ class BinarySensor(CoilEntity, BinarySensorEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 2daf3fc48ff..452244f05b5 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -41,14 +41,16 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) + self._off_value = coil.get_mapping_for(0) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._async_write_coil("ON") + await self._async_write_coil(self._on_value) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._async_write_coil("OFF") + await self._async_write_coil(self._off_value) diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index 15cd9859d6e..e5ce32b2293 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -24,6 +24,8 @@ MOCK_ENTRY_DATA = { "connection_type": "nibegw", } +MOCK_UNIQUE_ID = "mock_entry_unique_id" + class MockConnection(Connection): """A mock connection class.""" @@ -59,7 +61,9 @@ class MockConnection(Connection): async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> MockConfigEntry: """Add entry and get the coordinator.""" - entry = MockConfigEntry(domain=DOMAIN, title="Dummy", data=data) + entry = MockConfigEntry( + domain=DOMAIN, title="Dummy", data=data, unique_id=MOCK_UNIQUE_ID + ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..37dd7a8679c --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-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.eb101_installed_49239', + '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': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-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.eb101_installed_49239', + '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': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/snapshots/test_switch.ambr b/tests/components/nibe_heatpump/snapshots/test_switch.ambr new file mode 100644 index 00000000000..01f35bd8a54 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-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': , + 'entity_id': 'switch.holiday_activated_48043', + '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': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-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': , + 'entity_id': 'switch.holiday_activated_48043', + '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': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-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': , + 'entity_id': 'switch.flm_1_accessory_48071', + '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': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-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': , + 'entity_id': 'switch.flm_1_accessory_48071', + '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': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_binary_sensor.py b/tests/components/nibe_heatpump/test_binary_sensor.py new file mode 100644 index 00000000000..30010ac61c4 --- /dev/null +++ b/tests/components/nibe_heatpump/test_binary_sensor.py @@ -0,0 +1,49 @@ +"""Test the Nibe Heat Pump binary sensor entities.""" + +from typing import Any +from unittest.mock import patch + +from nibe.heatpump import Model +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 . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch( + "homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 49239, "OFF"), + (Model.F1255, 49239, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 5015bba4092..4f2bab7ad0a 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump buttons.""" from typing import Any from unittest.mock import AsyncMock, patch diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index a9620b5ddb3..85e932f8018 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump climate entities.""" from typing import Any from unittest.mock import call, patch diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index dc7faf0a80e..e054979b7a9 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump number entities.""" from typing import Any from unittest.mock import AsyncMock, patch diff --git a/tests/components/nibe_heatpump/test_switch.py b/tests/components/nibe_heatpump/test_switch.py new file mode 100644 index 00000000000..4221de52ba1 --- /dev/null +++ b/tests/components/nibe_heatpump/test_switch.py @@ -0,0 +1,133 @@ +"""Test the Nibe Heat Pump switch entities.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from nibe.coil import CoilData +from nibe.heatpump import Model +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_PLATFORM, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.SWITCH]): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 48043, "INACTIVE"), + (Model.F1255, 48043, "ACTIVE"), + (Model.F1255, 48071, "OFF"), + (Model.F1255, 48071, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "OFF"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_on( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 1 + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_off( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 0 From 91cdf1a367da31758b35dde4f3765f7882c74213 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 11:14:41 +0300 Subject: [PATCH 0165/1118] Bump aioamazondevices to 3.2.8 (#148365) Co-authored-by: Joakim Plate --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/conftest.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 70281390436..34fdd1448a5 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.2.3"] + "requirements": ["aioamazondevices==3.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index b5777776929..dada15791d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.3 +aioamazondevices==3.2.8 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0496d7bdd9a..38da863b93a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.3 +aioamazondevices==3.2.8 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 79851550528..a5a49a343a9 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -50,6 +50,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: device_type="echo", device_owner_customer_id="amazon_ower_id", device_cluster_members=[TEST_SERIAL_NUMBER], + device_locale="en-US", online=True, serial_number=TEST_SERIAL_NUMBER, software_version="echo_test_software_version", From 3b047859f92a318470efba72660c33f8c630ab96 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 8 Jul 2025 13:06:05 +0200 Subject: [PATCH 0166/1118] Create own clientsession for lamarzocco (#148385) --- homeassistant/components/lamarzocco/__init__.py | 5 ++--- homeassistant/components/lamarzocco/config_flow.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index ff977438f38..2d68b3be345 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( @@ -57,11 +57,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = async_get_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], - client=client, + client=async_create_clientsession(hass), ) try: diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 8cb2e4dfc61..e352e337d0b 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -83,7 +83,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, } - self._client = async_get_clientsession(self.hass) + self._client = async_create_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], From 435465e5695bf7fb96b187fd28a9db785148123c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 8 Jul 2025 13:06:43 +0200 Subject: [PATCH 0167/1118] Bump pylamarzocco to 2.0.11 (#148386) --- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 4fc2c0b05df..afbb779b696 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -66,7 +66,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF) ), ).status - is BackFlushStatus.REQUESTED + in (BackFlushStatus.REQUESTED, BackFlushStatus.CLEANING) ), entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: ( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 10cb23146ae..3c070769b5b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.10"] + "requirements": ["pylamarzocco==2.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index dada15791d2..d17b1030922 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2100,7 +2100,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.10 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38da863b93a..6ad4aa867bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.10 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 From d51a44acbc4f39c28b7a6c5e165a5112a08759c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 8 Jul 2025 12:34:51 +0200 Subject: [PATCH 0168/1118] Bump pySmartThings to 3.2.7 (#148394) --- 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 7c3fc47e512..2c4974a6567 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.2.5"] + "requirements": ["pysmartthings==3.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index d17b1030922..b15b6207ac0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.7 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ad4aa867bf..087be4dc645 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.7 # homeassistant.components.smarty pysmarty2==0.10.2 From b8425de0d00d69d1950a5cbd50f4972226966ce5 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:37:45 +0200 Subject: [PATCH 0169/1118] Bump uiprotect to version 7.14.2 (#148453) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 47e2a01e798..8243a55d779 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b15b6207ac0..c7cb2d62836 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2994,7 +2994,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 087be4dc645..9c8acdcd535 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From b6d316c8f2430a9a513ecdd275fb92a4c16a000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 9 Jul 2025 11:30:43 +0100 Subject: [PATCH 0170/1118] Bump hass-nabucasa from 0.105.0 to 0.106.0 (#148473) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0d44d57ac5e..7c64100873c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.105.0"], + "requirements": ["hass-nabucasa==0.106.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 824c3d945fe..6c6eac1f6dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.1 diff --git a/pyproject.toml b/pyproject.toml index b4478a69d53..4b9140296a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.105.0", + "hass-nabucasa==0.106.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 011d76e66b5..61aeaf81d14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c7cb2d62836..5218bb9068a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c8acdcd535..aabad61a45d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 00e2a177a5a432c4c2f668fe464c3ac23428f416 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 9 Jul 2025 13:53:15 +0200 Subject: [PATCH 0171/1118] Revert "Deprecate hddtemp" (#148482) --- homeassistant/components/hddtemp/__init__.py | 2 -- homeassistant/components/hddtemp/sensor.py | 20 +------------------ tests/components/hddtemp/test_sensor.py | 21 ++------------------ 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 66a819f1e8d..121238df9fe 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1,3 +1 @@ """The hddtemp component.""" - -DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 192ddffd330..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,14 +22,11 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN - _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -59,21 +56,6 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" - create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - breaks_in_ha_version="2025.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_system_packages_yaml_integration", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "hddtemp", - }, - ) - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 62882c7df8b..56ad9fdcb0e 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,15 +1,12 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from homeassistant.components.hddtemp import DOMAIN -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -195,17 +192,3 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -@patch.dict("sys.modules", gsp=Mock()) -async def test_repair_issue_is_created( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue is created.""" - assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) - await hass.async_block_till_done() - assert ( - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - ) in issue_registry.issues From e951fc401c792d570356253a7b229b214e5f821f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 11 Jul 2025 11:19:54 +0200 Subject: [PATCH 0172/1118] Fix entity_id should be based on object_id the first time an entity is added (#148484) --- homeassistant/components/mqtt/entity.py | 35 ++++++++++++------- tests/components/mqtt/test_discovery.py | 46 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 338779f32cb..f1594a7b034 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -389,16 +389,6 @@ def async_setup_entity_entry_helper( _async_setup_entities() -def init_entity_id_from_config( - hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str -) -> None: - """Set entity_id from object_id if defined in config.""" - if CONF_OBJECT_ID in config: - entity.entity_id = async_generate_entity_id( - entity_id_format, config[CONF_OBJECT_ID], None, hass - ) - - class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -1312,6 +1302,7 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str + _update_registry_entity_id: str | None = None def __init__( self, @@ -1346,13 +1337,33 @@ class MqttEntity( def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" - init_entity_id_from_config( - self.hass, self, self._config, self._entity_id_format + if CONF_OBJECT_ID not in self._config: + return + self.entity_id = async_generate_entity_id( + self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass ) + if self.unique_id is None: + return + # Check for previous deleted entities + entity_registry = er.async_get(self.hass) + entity_platform = self._entity_id_format.split(".")[0] + if ( + deleted_entry := entity_registry.deleted_entities.get( + (entity_platform, DOMAIN, self.unique_id) + ) + ) and deleted_entry.entity_id != self.entity_id: + # Plan to update the entity_id basis on `object_id` if a deleted entity was found + self._update_registry_entity_id = self.entity_id @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" + if self._update_registry_entity_id is not None: + entity_registry = er.async_get(self.hass) + entity_registry.async_update_entity( + self.entity_id, new_entity_id=self._update_registry_entity_id + ) + await super().async_added_to_hass() self._subscriptions = {} self._prepare_subscribe_topics() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 35a9a0494a6..04b4bda0d79 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1496,6 +1496,52 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered +async def test_discovery_with_object_id_for_previous_deleted_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test discovering an MQTT entity with object_id and unique_id.""" + + topic = "homeassistant/sensor/object/bla/config" + config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "hello_id", "state_topic": "test-topic" }' + ) + new_config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + ) + initial_entity_id = "sensor.hello_id" + new_entity_id = "sensor.updated_hello_id" + name = "Hello World 11" + domain = "sensor" + + await mqtt_mock_entry() + async_fire_mqtt_message(hass, topic, config) + await hass.async_block_till_done() + + state = hass.states.get(initial_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + # Delete the entity + async_fire_mqtt_message(hass, topic, "") + await hass.async_block_till_done() + assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered + + # Rediscover with new object_id + async_fire_mqtt_message(hass, topic, new_config) + await hass.async_block_till_done() + + state = hass.states.get(new_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + async def test_discovery_incl_nodeid( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: From 9c9836defdc1451939ccb9b518edd078b8fd5e76 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:22:31 +0200 Subject: [PATCH 0173/1118] Bump aioimmich to 0.10.2 (#148503) --- homeassistant/components/immich/manifest.json | 2 +- homeassistant/components/immich/update.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 80dcd87cd88..906356a4bc9 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.10.1"] + "requirements": ["aioimmich==0.10.2"] } diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py index 9955e355c96..e0af5c1c67f 100644 --- a/homeassistant/components/immich/update.py +++ b/homeassistant/components/immich/update.py @@ -44,7 +44,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity): return self.coordinator.data.server_about.version @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Available new immich server version.""" assert self.coordinator.data.server_version_check return self.coordinator.data.server_version_check.release_version diff --git a/requirements_all.txt b/requirements_all.txt index 5218bb9068a..b431371da33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aabad61a45d..5f5dd955b63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 962ad99c205bcce3ffb9cca5210c63fde5ba9880 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:44:50 +0200 Subject: [PATCH 0174/1118] Add workaround for sub units without main device in AVM Fritz!SmartHome (#148507) --- .../components/fritzbox/coordinator.py | 13 ++++-- tests/components/fritzbox/test_coordinator.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 8a37ebf63e4..a95af62da6c 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for device in new_data.devices.values(): # create device registry entry for new main devices - if ( - device.ain not in self.data.devices - and device.device_and_unit_id[1] is None + if device.ain not in self.data.devices and ( + device.device_and_unit_id[1] is None + or ( + # workaround for sub units without a main device, e.g. Energy 250 + # https://github.com/home-assistant/core/issues/145204 + device.device_and_unit_id[1] == "1" + and device.device_and_unit_id[0] not in new_data.devices + ) ): dr.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, name=device.name, - identifiers={(DOMAIN, device.ain)}, + identifiers={(DOMAIN, device.device_and_unit_id[0])}, manufacturer=device.manufacturer, model=device.productname, sw_version=device.fw_version, diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 61de0c99940..794d6ac4397 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock +from . import ( + FritzDeviceCoverMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, +) from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -140,3 +145,42 @@ async def test_coordinator_automatic_registry_cleanup( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 + + +async def test_coordinator_workaround_sub_units_without_main_device( + hass: HomeAssistant, + fritz: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the workaround for sub units without main device.""" + fritz().get_devices.return_value = [ + FritzDeviceSensorMock( + ain="bad_device-1", + device_and_unit_id=("bad_device", "1"), + name="bad_sensor_sub", + ), + FritzDeviceSensorMock( + ain="good_device", + device_and_unit_id=("good_device", None), + name="good_sensor", + ), + FritzDeviceSensorMock( + ain="good_device-1", + device_and_unit_id=("good_device", "1"), + name="good_sensor_sub", + ), + ] + + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 2 + assert device_entries[0].identifiers == {(DOMAIN, "good_device")} + assert device_entries[1].identifiers == {(DOMAIN, "bad_device")} From 0990cef917f28b604b7c78ffeab6cf3c0c10149b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 00:26:13 +0200 Subject: [PATCH 0175/1118] Add Home Connect resume command button when an appliance is paused (#148512) --- .../components/home_connect/coordinator.py | 30 ++++++++- tests/components/home_connect/test_button.py | 63 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3c9d33424a8..76faaefa931 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -41,7 +41,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import ( + API_DEFAULT_RETRY_AFTER, + APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_PAUSE, + DOMAIN, +) from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -66,6 +71,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.clear() self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected @@ -201,6 +207,28 @@ class HomeConnectCoordinator( raw_key=status_key.value, value=event.value, ) + if ( + status_key == StatusKey.BSH_COMMON_OPERATION_STATE + and event.value == BSH_OPERATION_STATE_PAUSE + and CommandKey.BSH_COMMON_RESUME_PROGRAM + not in ( + commands := self.data[ + event_message_ha_id + ].commands + ) + ): + # All the appliances that can be paused + # should have the resume command available. + commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM) + for ( + listener, + context, + ) in self._special_listeners.values(): + if ( + EventKey.BSH_COMMON_APPLIANCE_DEPAIRED + not in context + ): + listener() self._call_event_listener(event_message) case EventType.NOTIFY: diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index ee4d5f1d729..e61ec5e2b1f 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -1,12 +1,14 @@ """Tests for home_connect button entities.""" from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfCommands, CommandKey, + Event, + EventKey, EventMessage, HomeAppliance, ) @@ -317,3 +319,62 @@ async def test_stop_program_button_exception( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_enable_resume_command_on_pause( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test if all commands enabled option works as expected.""" + entity_id = "button.washer_resume_program" + + original_get_available_commands = client.get_available_commands + + async def get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + array_of_commands = cast( + ArrayOfCommands, await original_get_available_commands(ha_id) + ) + if ha_id == appliance.ha_id: + for command in array_of_commands.commands: + if command.key == CommandKey.BSH_COMMON_RESUME_PROGRAM: + # Simulate that the resume command is not available initially + array_of_commands.commands.remove(command) + break + return array_of_commands + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE.value, + timestamp=0, + level="", + handling="", + value="BSH.Common.EnumType.OperationState.Pause", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) From d87379d0832109f681cc08a88556e414c25f2a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 09:35:30 +0200 Subject: [PATCH 0176/1118] Use the link to the issue instead of creating new issues at Home Connect (#148523) --- homeassistant/components/home_connect/coordinator.py | 5 +---- homeassistant/components/home_connect/strings.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 76faaefa931..bb419f6bd7c 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -655,10 +655,7 @@ class HomeConnectCoordinator( "times": str(MAX_EXECUTIONS), "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_new_issue_url": ( - "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" - f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" - ), + "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", }, ) return True diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 99c89ec8788..e1c0b42ca0b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -130,7 +130,7 @@ "step": { "confirm": { "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." } } } From 63b21fda1a1783dfe102e4bff20729b2e8aa0b22 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 11 Jul 2025 09:06:18 +0200 Subject: [PATCH 0177/1118] Ensure response is fully read to prevent premature connection closure in rest command (#148532) --- .../components/rest_command/__init__.py | 5 ++++ tests/components/rest_command/test_init.py | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c6a4206de4a..32eb1aae2b0 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -178,6 +178,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not service.return_response: + # always read the response to avoid closing the connection + # before the server has finished sending it, while avoiding excessive memory usage + async for _ in response.content.iter_chunked(1024): + pass + return None _content = None diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 97ef29dfaca..6ba3da8b867 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -326,7 +326,7 @@ async def test_rest_command_get_response_malformed_json( aioclient_mock.get( TEST_URL, - content='{"status": "failure", 42', + content=b'{"status": "failure", 42', headers={"content-type": "application/json"}, ) @@ -379,3 +379,27 @@ async def test_rest_command_get_response_none( ) assert not response + + +async def test_rest_command_response_iter_chunked( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Ensure response is consumed when return_response is False.""" + await setup_component() + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + aioclient_mock.get(TEST_URL, content=png) + + with patch("aiohttp.StreamReader.iter_chunked", autospec=True) as mock_iter_chunked: + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + # Ensure the response is not returned + assert response is None + + # Verify iter_chunked was called with a chunk size + assert mock_iter_chunked.called From 5cf5be8c9cc03e35c6812144a2b35aae70b0a139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Thu, 10 Jul 2025 10:52:03 +0200 Subject: [PATCH 0178/1118] Fix for Renson set Breeze fan speed (#148537) --- homeassistant/components/renson/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 474ab640943..c82cad012c3 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -196,7 +196,7 @@ class RensonFan(RensonEntity, FanEntity): all_data = self.coordinator.data breeze_temp = self.api.get_field_value(all_data, BREEZE_TEMPERATURE_FIELD) await self.hass.async_add_executor_job( - self.api.set_breeze, cmd.name, breeze_temp, True + self.api.set_breeze, cmd, breeze_temp, True ) else: await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) From 3c2fa023b430cb75ade1537d52764a36e30f5fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 10 Jul 2025 12:09:24 +0200 Subject: [PATCH 0179/1118] Remove vg argument from miele auth flow (#148541) --- homeassistant/components/miele/config_flow.py | 8 -------- tests/components/miele/test_config_flow.py | 4 ---- 2 files changed, 12 deletions(-) diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py index d3c7dbba12b..191cd9a0454 100644 --- a/homeassistant/components/miele/config_flow.py +++ b/homeassistant/components/miele/config_flow.py @@ -26,14 +26,6 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - # "vg" is mandatory but the value doesn't seem to matter - return { - "vg": "sv-SE", - } - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py index bbe5844c1cd..5ce129b255d 100644 --- a/tests/components/miele/test_config_flow.py +++ b/tests/components/miele/test_config_flow.py @@ -46,7 +46,6 @@ async def test_full_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -118,7 +117,6 @@ async def test_flow_reauth_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -187,7 +185,6 @@ async def test_flow_reconfigure_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -247,7 +244,6 @@ async def test_zeroconf_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() From f1272ef513df9e2b23294832ac83f4a6d1ec7f4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Jul 2025 11:10:48 -1000 Subject: [PATCH 0180/1118] Bump aiohttp to 3.12.14 (#148565) --- homeassistant/components/http/ban.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 7e55191639b..71f3d54bef6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) # type: ignore[arg-type] + app.on_startup.append(ban_startup) @middleware diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6c6eac1f6dd..9a02b999775 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 4b9140296a7..17fb74f452c 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.1", - "aiohttp==3.12.13", + "aiohttp==3.12.14", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 61aeaf81d14..67c38d690b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.1 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From dc2736580f5d9508cf3b9cc9baa98fa469cca564 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 10 Jul 2025 23:09:47 +0200 Subject: [PATCH 0181/1118] Update frontend to 20250702.2 (#148573) --- 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 748d8f0c6f0..a7582ebc5e2 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==20250702.1"] + "requirements": ["home-assistant-frontend==20250702.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a02b999775..01bf8e24885 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b431371da33..02520158cd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f5dd955b63..374fc8cc36b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 150d4716facf376f156e46567aa181f8775d0f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 12 Jul 2025 01:53:38 +0700 Subject: [PATCH 0182/1118] Fix Google Cloud 504 Deadline Exceeded (#148589) --- homeassistant/components/google_cloud/stt.py | 2 +- homeassistant/components/google_cloud/tts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index cd5055383ea..8a548cde8bb 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): try: responses = await self._client.streaming_recognize( requests=request_generator(), - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 16519645dee..817c424d1fc 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -218,7 +218,7 @@ class BaseGoogleCloudProvider: response = await self._client.synthesize_speech( request, - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) From 80c52ad8ea29db07dccf0b4c1f67edae178d60b2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:25:22 +0200 Subject: [PATCH 0183/1118] Fix - only enable AlexaModeController if at least one mode is offered (#148614) --- homeassistant/components/alexa/entities.py | 23 ++- tests/components/alexa/test_entities.py | 165 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7088b624e21..5f789813869 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity): ): yield AlexaThermostatController(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity) - if self.entity.domain == water_heater.DOMAIN and ( - supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + if ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) ): yield AlexaModeController( self.entity, @@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity): self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) force_range_controller = False - if supported & fan.FanEntityFeature.PRESET_MODE: + if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( + fan.ATTR_PRESET_MODES + ): yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) @@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity): yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] - if activities and supported & remote.RemoteEntityFeature.ACTIVITY: + if ( + activities + and (supported & remote.RemoteEntityFeature.ACTIVITY) + and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + ): yield AlexaModeController( self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" ) @@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & humidifier.HumidifierEntityFeature.MODES: + if ( + supported & humidifier.HumidifierEntityFeature.MODES + ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): yield AlexaModeController( self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" ) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 6998b2acc97..4d8d0dca67f 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import fan, humidifier, remote, water_heater from homeassistant.components.alexa import smart_home from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant @@ -200,3 +201,167 @@ async def test_serialize_discovery_recovers( "Error serializing Alexa.PowerController discovery" f" for {hass.states.get('switch.bla')}" ) in caplog.text + + +@pytest.mark.parametrize( + ("domain", "state", "state_attributes", "mode_controller_exists"), + [ + ("switch", "on", {}, False), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": "eco", + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": [], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + False, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto", "manual"], + "mode": "auto", + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto"], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": [], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + False, + ), + ( + "remote", + "on", + { + "activity_list": ["tv", "dvd"], + "current_activity": "tv", + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": ["tv"], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": [], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + False, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on", "auto"], + "operation_mode": "auto", + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on"], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": [], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + False, + ), + ], +) +async def test_mode_controller_is_omitted_if_no_modes_are_set( + hass: HomeAssistant, + domain: str, + state: str, + state_attributes: dict[str, Any], + mode_controller_exists: bool, +) -> None: + """Test we do not generate an invalid discovery with AlexaModeController during serialize discovery. + + AlexModeControllers need at least 2 modes. If one mode is set, an extra mode will be added for compatibility. + If no modes are offered, the mode controller should be omitted to prevent schema validations. + """ + request = get_new_request("Alexa.Discovery", "Discover") + + hass.states.async_set( + f"{domain}.bla", state, {"friendly_name": "Boop Woz"} | state_attributes + ) + + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + msg = msg["event"] + + interfaces = { + ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"] + } + + assert ("Alexa.ModeController" in interfaces) is mode_controller_exists From 649fbfc729a40f05a848e50043b2c13a64a6cace Mon Sep 17 00:00:00 2001 From: falconindy Date: Sat, 12 Jul 2025 14:46:03 -0400 Subject: [PATCH 0184/1118] snoo: use correct value for right safety clip binary sensor (#148647) --- homeassistant/components/snoo/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py index 3c91db5b86d..c4eaddcc1fe 100644 --- a/homeassistant/components/snoo/binary_sensor.py +++ b/homeassistant/components/snoo/binary_sensor.py @@ -38,7 +38,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ SnooBinarySensorEntityDescription( key="right_clip", translation_key="right_clip", - value_fn=lambda data: data.left_safety_clip, + value_fn=lambda data: data.right_safety_clip, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), From c802430066bbf61004c7ed7789ccf2947ecef7bf Mon Sep 17 00:00:00 2001 From: 0xEF <48224539+hexEF@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:30:26 +0200 Subject: [PATCH 0185/1118] Bump nyt_games to 0.5.0 (#148654) --- .../components/nyt_games/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/nyt_games/fixtures/latest.json | 57 ++++++++++--------- .../nyt_games/fixtures/new_account.json | 45 ++++++++------- .../nyt_games/snapshots/test_sensor.ambr | 8 +-- 6 files changed, 61 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index c32de754782..db3ad6a85f1 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.4.4"] + "requirements": ["nyt_games==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02520158cd4..3eb2312b079 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 374fc8cc36b..c7e94225cf0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/fixtures/latest.json b/tests/components/nyt_games/fixtures/latest.json index 73a6f440fc0..16601243052 100644 --- a/tests/components/nyt_games/fixtures/latest.json +++ b/tests/components/nyt_games/fixtures/latest.json @@ -25,43 +25,46 @@ }, "wordle": { "legacyStats": { - "gamesPlayed": 70, - "gamesWon": 51, + "gamesPlayed": 1111, + "gamesWon": 1069, "guesses": { "1": 0, - "2": 1, - "3": 7, - "4": 11, - "5": 20, - "6": 12, - "fail": 19 + "2": 8, + "3": 83, + "4": 440, + "5": 372, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonDayOffset": 1189, + "currentStreak": 229, + "maxStreak": 229, + "lastWonDayOffset": 1472, "hasPlayed": true, - "autoOptInTimestamp": 1708273168957, - "hasMadeStatsChoice": false, - "timestamp": 1726831978 + "autoOptInTimestamp": 1712205417018, + "hasMadeStatsChoice": true, + "timestamp": 1751255756 }, "calculatedStats": { - "gamesPlayed": 33, - "gamesWon": 26, + "currentStreak": 237, + "maxStreak": 241, + "lastWonPrintDate": "2025-07-08", + "lastCompletedPrintDate": "2025-07-08", + "hasPlayed": true + }, + "totalStats": { + "gamesWon": 1077, + "gamesPlayed": 1119, "guesses": { "1": 0, - "2": 1, - "3": 4, - "4": 7, - "5": 10, - "6": 4, - "fail": 7 + "2": 8, + "3": 83, + "4": 444, + "5": 376, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonPrintDate": "2024-09-20", - "lastCompletedPrintDate": "2024-09-20", "hasPlayed": true, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/fixtures/new_account.json b/tests/components/nyt_games/fixtures/new_account.json index ad4d8e2e416..d35ce4cdebc 100644 --- a/tests/components/nyt_games/fixtures/new_account.json +++ b/tests/components/nyt_games/fixtures/new_account.json @@ -7,26 +7,6 @@ "stats": { "wordle": { "legacyStats": { - "gamesPlayed": 1, - "gamesWon": 1, - "guesses": { - "1": 0, - "2": 0, - "3": 0, - "4": 0, - "5": 1, - "6": 0, - "fail": 0 - }, - "currentStreak": 0, - "maxStreak": 1, - "lastWonDayOffset": 1118, - "hasPlayed": true, - "autoOptInTimestamp": 1727357874700, - "hasMadeStatsChoice": false, - "timestamp": 1727358123 - }, - "calculatedStats": { "gamesPlayed": 0, "gamesWon": 0, "guesses": { @@ -38,12 +18,35 @@ "6": 0, "fail": 0 }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonDayOffset": 1118, + "hasPlayed": true, + "autoOptInTimestamp": 1727357874700, + "hasMadeStatsChoice": false, + "timestamp": 1727358123 + }, + "calculatedStats": { "currentStreak": 0, "maxStreak": 1, "lastWonPrintDate": "", "lastCompletedPrintDate": "", + "hasPlayed": false + }, + "totalStats": { + "gamesPlayed": 1, + "gamesWon": 1, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 1, + "6": 0, + "fail": 0 + }, "hasPlayed": false, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 5a1aa384f0f..10fddcfa365 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -473,7 +473,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '237', }) # --- # name: test_all_entities[sensor.wordle_highest_streak-entry] @@ -529,7 +529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '241', }) # --- # name: test_all_entities[sensor.wordle_played-entry] @@ -581,7 +581,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70', + 'state': '1119', }) # --- # name: test_all_entities[sensor.wordle_won-entry] @@ -633,6 +633,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '51', + 'state': '1077', }) # --- From c4ddcd64c827ff61b6e0d7a82e2e47e4d8a58f4a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 14 Jul 2025 18:29:29 +1000 Subject: [PATCH 0186/1118] Fix Charge Cable binary sensor in Teslemetry (#148675) --- homeassistant/components/teslemetry/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 439df76c838..6905cefdc30 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -125,8 +125,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", - streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value is not None and value != "Unknown") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback(None if value is None else value != "Disconnected") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, From d4374dbcc77c14a20eadf7aa28afb93199f35fc0 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:08:54 +0200 Subject: [PATCH 0187/1118] Bump PyViCare to 2.50.0 (#148679) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index fed777e6435..8e632e46efe 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.44.0"] + "requirements": ["PyViCare==2.50.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3eb2312b079..8361bf5cefd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7e94225cf0..6cf669bb013 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From f7672985ed3bc0fa2c1cee5c135a66ced5d1b10d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:26:37 +0200 Subject: [PATCH 0188/1118] Fix hide empty sections in mqtt subentry flows (#148692) --- homeassistant/components/mqtt/config_flow.py | 3 ++ tests/components/mqtt/test_config_flow.py | 51 +++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ee451b5f81d..a3cf2d1d12f 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2114,6 +2114,9 @@ def data_schema_from_fields( if schema_section is None: data_schema.update(data_schema_element) continue + if not data_schema_element: + # Do not show empty sections + continue collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9386f1da32c..77c74001939 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3220,7 +3220,7 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process entity details setep + # Process entity details step assert result["step_id"] == "entity_platform_config" # First test validators if set of test @@ -4212,3 +4212,52 @@ async def test_subentry_reconfigure_availablity( "payload_available": "1", "payload_not_available": "0", } + + +async def test_subentry_configflow_section_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow sections are hidden when they have no configurable options.""" + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Bla", "mqtt_settings": {"qos": 1}}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"platform": "fan"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + + # Process entity details step + assert result["step_id"] == "entity_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"fan_feature_speed": True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "mqtt_platform_config" + + # Check mqtt platform config flow sections from data schema + data_schema = result["data_schema"].schema + assert "fan_speed_settings" in data_schema + assert "fan_preset_mode_settings" not in data_schema From 80384b89a5b3b4f1bc48e471d660f298ae507ea3 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 14 Jul 2025 01:15:50 +0300 Subject: [PATCH 0189/1118] Bump aioshelly to 13.7.2 (#148706) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 1db8dbf55c6..08c9163bb3b 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.1"], + "requirements": ["aioshelly==13.7.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 8361bf5cefd..c3b3239101e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6cf669bb013..b34163d2ee8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 From 691a0ca065aba13f2737cc66d0427087f056a52d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Jul 2025 01:18:35 +0300 Subject: [PATCH 0190/1118] Bump aioamazondevices to 3.2.10 (#148709) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 34fdd1448a5..0cb99ba090e 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.2.8"] + "requirements": ["aioamazondevices==3.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3b3239101e..c7d125c0700 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b34163d2ee8..a388e6dbf87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 87af9fc8baab7a248fcfaf9fd89955e7f6b941cf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 14 Jul 2025 10:30:35 +0000 Subject: [PATCH 0191/1118] Bump version to 2025.7.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 258a9c9a48f..9dd3c3480f9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 17fb74f452c..47c38246f29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.1" +version = "2025.7.2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 155fc134b6fabe1ed092d7a434ebd5c5b44af32e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 13:33:00 +0200 Subject: [PATCH 0192/1118] Do not add derivative config entry to source device (#148674) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/derivative/__init__.py | 26 +++- .../components/derivative/config_flow.py | 2 +- homeassistant/components/derivative/sensor.py | 18 +-- homeassistant/helpers/device.py | 13 ++ homeassistant/helpers/helper_integration.py | 21 ++- tests/components/derivative/test_init.py | 143 ++++++++++++++++-- tests/helpers/test_device.py | 46 +++++- tests/helpers/test_helper_integration.py | 71 ++++++++- 8 files changed, 301 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 8fb614a3de4..8bdf448bfba 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -11,7 +11,10 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) _LOGGER = logging.getLogger(__name__) @@ -19,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE] ) @@ -29,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE], - source_entity_removed=source_entity_removed, ) ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) @@ -85,6 +85,20 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, options=new_options, version=1, minor_version=2 ) + if config_entry.minor_version < 3: + # Remove the derivative config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_SOURCE] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=3 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index c90631f3aeb..b5dee1deee3 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -142,7 +142,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index bfba2f0023c..ab4feabc4ee 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -34,8 +34,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -118,17 +117,13 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: max_sub_interval = None derivative_sensor = DerivativeSensor( + hass, name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, @@ -137,7 +132,6 @@ async def async_setup_entry( unit_of_measurement=None, unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -152,6 +146,7 @@ async def async_setup_platform( ) -> None: """Set up the derivative sensor.""" derivative = DerivativeSensor( + hass, name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE], @@ -174,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, *, name: str | None, round_digits: int, @@ -184,11 +180,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_time: UnitOfTime, max_sub_interval: timedelta | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._sensor_source_id = source_entity self._round_digits = round_digits self._attr_native_value = round(Decimal(0), round_digits) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index f1404bb068b..bf0e2ab31be 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -21,6 +21,19 @@ def async_entity_id_to_device_id( return entity.device_id +@callback +def async_entity_id_to_device( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> dr.DeviceEntry | None: + """Resolve the device entry for the entity id or entity uuid.""" + + if (device_id := async_entity_id_to_device_id(hass, entity_id_or_uuid)) is None: + return None + + return dr.async_get(hass).async_get(device_id) + + @callback def async_device_info_to_link_from_entity( hass: HomeAssistant, diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index d43c1b22a25..04a1d2cca76 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -19,13 +19,15 @@ def async_handle_source_entity_changes( set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, - source_entity_removed: Callable[[], Coroutine[Any, Any, None]], + source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. The following changes are handled: - - Entity removal: If the source entity is removed, the helper config entry - is removed, and the helper entity is cleaned up. + - Entity removal: If the source entity is removed: + - If source_entity_removed is provided, it is called to handle the removal. + - If source_entity_removed is not provided, The helper entity is updated to + not link to any device. - Entity ID changed: If the source entity's entity ID changes and the source entity is identified by an entity ID, the set_source_entity_id_or_uuid is called. If the source entity is identified by a UUID, the helper config entry @@ -52,7 +54,18 @@ def async_handle_source_entity_changes( data = event.data if data["action"] == "remove": - await source_entity_removed() + if source_entity_removed: + await source_entity_removed() + else: + for ( + helper_entity_entry + ) in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity_entry.entity_id, device_id=None + ) if data["action"] != "update": return diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 1f7d051d27e..abe90e72b56 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import derivative from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -82,6 +82,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(derivative_config_entry.entry_id) @@ -229,7 +230,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -240,6 +241,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the derivative config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -256,7 +305,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -273,7 +322,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -300,7 +353,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -315,7 +368,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -348,7 +405,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert derivative_config_entry.entry_id not in sensor_device_2.config_entries @@ -365,11 +422,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is moved to the other device + # Check that the entity is linked to the other device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert derivative_config_entry.entry_id in sensor_device_2.config_entries + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -394,7 +455,7 @@ async def test_async_handle_source_entity_new_entity_id( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -412,9 +473,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the derivative config entry is updated with the new entity ID assert derivative_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -431,7 +492,7 @@ async def test_async_handle_source_entity_new_entity_id( ({"unit_prefix": "none"}, None), ], ) -async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: +async def test_migration_1_1(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: """Test migration from v1.1 deletes "none" unit_prefix.""" config_entry = MockConfigEntry( @@ -457,6 +518,60 @@ async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> Non assert config_entry.options["unit_time"] == "min" assert config_entry.options.get("unit_prefix") == expect_prefix + assert config_entry.version == 1 + assert config_entry.minor_version == 3 + + +async def test_migration_1_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.2 removes derivative config entry from device.""" + + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.test_unique", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=2, + ) + derivative_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + assert derivative_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + assert derivative_config_entry.version == 1 + assert derivative_config_entry.minor_version == 3 + async def test_migration_from_future_version( hass: HomeAssistant, diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 266435ef05d..262e700c29e 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -8,6 +8,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device import ( async_device_info_to_link_from_device_id, async_device_info_to_link_from_entity, + async_entity_id_to_device, async_entity_id_to_device_id, async_remove_stale_devices_links_keep_current_device, async_remove_stale_devices_links_keep_entity_device, @@ -16,12 +17,12 @@ from homeassistant.helpers.device import ( from tests.common import MockConfigEntry -async def test_entity_id_to_device_id( +async def test_entity_id_to_device_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test returning an entity's device ID.""" + """Test returning an entity's device / device ID.""" config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -48,6 +49,41 @@ async def test_entity_id_to_device_id( entity_id_or_uuid=entity.entity_id, ) assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.entity_id, + ) + == device + ) + + assert ( + async_entity_id_to_device_id( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + + device_id = async_entity_id_to_device_id( + hass, + entity_id_or_uuid=entity.id, + ) + assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.id, + ) + == device + ) with pytest.raises(vol.Invalid): async_entity_id_to_device_id( @@ -55,6 +91,12 @@ async def test_entity_id_to_device_id( entity_id_or_uuid="unknown_uuid", ) + with pytest.raises(vol.Invalid): + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown_uuid", + ) + async def test_device_info_to_link( hass: HomeAssistant, diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 91932a51ac2..640b2ff011a 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -155,7 +155,7 @@ def mock_helper_integration( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, - source_entity_removed: AsyncMock, + source_entity_removed: AsyncMock | None, ) -> None: """Mock the helper integration.""" @@ -197,7 +197,9 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s return events -def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: +def listen_entity_registry_events( + hass: HomeAssistant, +) -> list[er.EventEntityRegistryUpdatedData]: """Track entity registry actions for an entity.""" events: list[er.EventEntityRegistryUpdatedData] = [] @@ -211,6 +213,7 @@ def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: return events +@pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +228,70 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is not removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_custom_handler( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" From 5e4ce46daea65301fa1e2cd6547d81d997222c8c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:38:33 +0200 Subject: [PATCH 0193/1118] Use absolute humidity device class in Airq (#148568) --- homeassistant/components/airq/const.py | 1 - homeassistant/components/airq/icons.json | 3 --- homeassistant/components/airq/sensor.py | 8 +++----- homeassistant/components/airq/strings.json | 3 --- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 7a5abe47a8d..3e5c736c8c5 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average" CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json index fec6eb8dd86..09f262aeaaf 100644 --- a/homeassistant/components/airq/icons.json +++ b/homeassistant/components/airq/icons.json @@ -4,9 +4,6 @@ "health_index": { "default": "mdi:heart-pulse" }, - "absolute_humidity": { - "default": "mdi:water" - }, "oxygen": { "default": "mdi:leaf" }, diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 08a344ae9f4..516114840d3 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirQConfigEntry, AirQCoordinator -from .const import ( - ACTIVITY_BECQUEREL_PER_CUBIC_METER, - CONCENTRATION_GRAMS_PER_CUBIC_METER, -) +from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER _LOGGER = logging.getLogger(__name__) @@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity_abs", - translation_key="absolute_humidity", + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 9c16975a3ab..de8c7d86b09 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -93,9 +93,6 @@ "health_index": { "name": "Health index" }, - "absolute_humidity": { - "name": "Absolute humidity" - }, "hydrogen": { "name": "Hydrogen" }, From 14ff04200e051f6eb0a65e4ee9d9856eca7486e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Jul 2025 16:24:44 +0200 Subject: [PATCH 0194/1118] Make AI Task instructions multiline (#148606) --- homeassistant/components/ai_task/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 4298ab62a07..194c0e07bc3 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -10,6 +10,7 @@ generate_data: required: true selector: text: + multiline: true entity_id: required: false selector: From 9e022ad75eb601e8ff86988736f4c6aa4f2f61df Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 14 Jul 2025 18:44:11 +0300 Subject: [PATCH 0195/1118] Quality fixes for Jewish Calendar (#148689) --- .../jewish_calendar/binary_sensor.py | 70 +++++-------------- .../components/jewish_calendar/entity.py | 61 ++++++++++++++++ .../components/jewish_calendar/sensor.py | 65 +++-------------- .../components/jewish_calendar/services.py | 1 - .../jewish_calendar/test_binary_sensor.py | 19 +---- .../jewish_calendar/test_config_flow.py | 7 +- .../components/jewish_calendar/test_sensor.py | 18 ----- .../jewish_calendar/test_service.py | 44 +++++++----- 8 files changed, 114 insertions(+), 171 deletions(-) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 79b49050cc2..d5097df962f 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -23,36 +22,29 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity PARALLEL_UPDATES = 0 -@dataclass(frozen=True) -class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): - """Binary Sensor description mixin class for Jewish Calendar.""" - - is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False - - -@dataclass(frozen=True) -class JewishCalendarBinarySensorEntityDescription( - JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class JewishCalendarBinarySensorEntityDescription(BinarySensorEntityDescription): """Binary Sensor Entity description for Jewish Calendar.""" + is_on: Callable[[Zmanim], Callable[[dt.datetime], bool]] + BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", translation_key="issur_melacha_in_effect", - is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), + is_on=lambda state: state.issur_melacha_in_effect, ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", translation_key="erev_shabbat_hag", - is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), + is_on=lambda state: state.erev_shabbat_chag, entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", translation_key="motzei_shabbat_hag", - is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), + is_on=lambda state: state.motzei_shabbat_chag, entity_registry_enabled_default=False, ), ) @@ -73,9 +65,7 @@ async def async_setup_entry( class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBinarySensorEntityDescription @@ -83,40 +73,12 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim, dt_util.now()) + return self.entity_description.is_on(zmanim)(dt_util.now()) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - @callback - def _update(self, now: dt.datetime | None = None) -> None: - """Update the state of the sensor.""" - self._update_unsub = None - self._schedule_update() - self.async_write_ha_state() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(dt.date.today()) - update = zmanim.netz_hachama.local + dt.timedelta(days=1) - candle_lighting = zmanim.candle_lighting - if candle_lighting is not None and now < candle_lighting < update: - update = candle_lighting - havdalah = zmanim.havdalah - if havdalah is not None and now < havdalah < update: - update = havdalah - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update, update - ) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + return [ + zmanim.netz_hachama.local + dt.timedelta(days=1), + zmanim.candle_lighting, + zmanim.havdalah, + ] diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 9d713aad0eb..d5e41129075 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,17 +1,24 @@ """Entity representing a Jewish Calendar sensor.""" +from abc import abstractmethod from dataclasses import dataclass import datetime as dt +import logging from hdate import HDateInfo, Location, Zmanim from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.util import dt as dt_util from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] @@ -39,6 +46,8 @@ class JewishCalendarEntity(Entity): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True + _attr_should_poll = False + _update_unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -63,3 +72,55 @@ class JewishCalendarEntity(Entity): candle_lighting_offset=self.data.candle_lighting_offset, havdalah_offset=self.data.havdalah_offset, ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + @abstractmethod + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" + now = dt_util.now() + zmanim = self.make_zmanim(now.date()) + update = dt_util.start_of_local_day() + dt.timedelta(days=1) + + for update_time in self._update_times(zmanim): + if update_time is not None and now < update_time < update: + update = update_time + + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update, update + ) + + @callback + def _update(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.create_results(now) + self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 6479a61c713..d9ad89237f5 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -17,16 +17,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .entity import ( - JewishCalendarConfigEntry, - JewishCalendarDataResults, - JewishCalendarEntity, -) +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -217,7 +212,7 @@ async def async_setup_entry( config_entry: JewishCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Jewish calendar sensors .""" + """Set up the Jewish calendar sensors.""" sensors: list[JewishCalendarBaseSensor] = [ JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] @@ -231,59 +226,15 @@ async def async_setup_entry( class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): """Base class for Jewish calendar sensors.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBaseSensorDescription - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(now.date()) - update = None - if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(zmanim) - next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) - if update is None or now > update: - update = next_midnight - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update_data, update - ) - - @callback - def _update_data(self, now: dt.datetime | None = None) -> None: - """Update the sensor data.""" - self._update_unsub = None - self._schedule_update() - self.create_results(now) - self.async_write_ha_state() - - def create_results(self, now: dt.datetime | None = None) -> None: - """Create the results for the sensor.""" - if now is None: - now = dt_util.now() - - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - zmanim = self.make_zmanim(today) - dateinfo = HDateInfo(today, diaspora=self.data.diaspora) - self.data.results = JewishCalendarDataResults(dateinfo, zmanim) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + if self.entity_description.next_update_fn is None: + return [] + return [self.entity_description.next_update_fn(zmanim)] def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: """Get the next date info.""" diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index 6fdebe6f74d..f77f9be4e64 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -50,7 +50,6 @@ def async_setup_services(hass: HomeAssistant) -> None: today = now.date() event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="sunset_event" ) diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 46f5fdfcc7d..a4c9fd02be3 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -6,11 +6,8 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed @@ -140,17 +137,3 @@ async def test_issur_melacha_sensor_update( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results[1] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert BINARY_SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert BINARY_SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 7a8b6b8df1e..a63d9abb9a7 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -28,19 +28,18 @@ from tests.common import MockConfigEntry async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test user config.""" - await setup.async_setup_component(hass, "persistent_notification", {}) 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" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DIASPORA: DEFAULT_DIASPORA, CONF_LANGUAGE: DEFAULT_LANGUAGE}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 38a3dd12206..ab24d35f932 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -8,11 +8,7 @@ from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_PLATFORM 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, async_fire_time_changed @@ -569,17 +565,3 @@ async def test_sensor_does_not_update_on_time_change( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results["new_state"] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 4b3f31d11d4..ce5ccf2af37 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -4,7 +4,13 @@ import datetime as dt import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.jewish_calendar.const import ( + ATTR_AFTER_SUNSET, + ATTR_DATE, + ATTR_NUSACH, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant @@ -14,10 +20,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 3, 20), - "nusach": "sfarad", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 3, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "", id="no_blessing", @@ -25,10 +31,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "ashkenaz", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "ashkenaz", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", id="ahskenaz-hebrew", @@ -36,10 +42,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": True, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: True, }, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset", @@ -47,23 +53,23 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: False, }, "Today is the thirty-seventh day, which are five weeks and two days of the Omer", id="sefarad-english-before-sunset", ), pytest.param( dt.datetime(2025, 5, 20, 21, 0), - {"nusach": "sfarad", "language": "en"}, + {ATTR_NUSACH: "sfarad", CONF_LANGUAGE: "en"}, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset-without-date", ), pytest.param( dt.datetime(2025, 5, 20, 6, 0), - {"nusach": "sfarad"}, + {ATTR_NUSACH: "sfarad"}, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", id="sefarad-english-before-sunset-without-date", ), From f08d1e547fa384c9c0b0221f6ef4082f590ee1d6 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:04:00 +0200 Subject: [PATCH 0196/1118] Fix adding a work area in Husqvarna Automower (#148358) --- .../husqvarna_automower/coordinator.py | 51 +++++++++----- .../husqvarna_automower/test_init.py | 70 ++++++++++++++----- 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 70af5219d04..342f6892b2e 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -60,15 +60,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - - def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: - """Add/remove devices and dynamic entities, when amount of devices changed.""" - self._async_add_remove_devices(data) - for mower_id in data: - if data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones(data) - if data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas(data) + self.async_add_listener(self._on_data_update) async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" @@ -82,14 +74,38 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): raise UpdateFailed(err) from err except AuthError as err: raise ConfigEntryAuthFailed(err) from err - self._async_add_remove_devices_and_entities(data) return data + @callback + def _on_data_update(self) -> None: + """Handle data updates and process dynamic entity management.""" + if self.data is not None: + self._async_add_remove_devices() + for mower_id in self.data: + if self.data[mower_id].capabilities.stay_out_zones: + self._async_add_remove_stay_out_zones() + if self.data[mower_id].capabilities.work_areas: + self._async_add_remove_work_areas() + @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.hass.async_create_task(self._process_websocket_update(ws_data)) + + async def _process_websocket_update(self, ws_data: MowerDictionary) -> None: + """Handle incoming websocket update and update coordinator data.""" + for data in ws_data.values(): + existing_areas = data.work_areas or {} + for task in data.calendar.tasks: + work_area_id = task.work_area_id + if work_area_id is not None and work_area_id not in existing_areas: + _LOGGER.debug( + "New work area %s detected, refreshing data", work_area_id + ) + await self.async_request_refresh() + return + self.async_set_updated_data(ws_data) - self._async_add_remove_devices_and_entities(ws_data) @callback def async_set_updated_data(self, data: MowerDictionary) -> None: @@ -138,9 +154,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) - def _async_add_remove_devices(self, data: MowerDictionary) -> None: + def _async_add_remove_devices(self) -> None: """Add new device, remove non-existing device.""" - current_devices = set(data) + current_devices = set(self.data) # Skip update if no changes if current_devices == self._devices_last_update: @@ -155,7 +171,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Process new device new_devices = current_devices - self._devices_last_update if new_devices: - self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) @@ -179,11 +194,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): for mower_callback in self.new_devices_callbacks: mower_callback(new_devices) - def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: + def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.stay_out_zones and mower_data.stay_out_zones is not None } @@ -225,11 +240,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): return current_zones - def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: + def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.work_areas and mower_data.work_areas is not None } diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 9a45b2ad42d..f54250a3336 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -3,7 +3,7 @@ from asyncio import Event from collections.abc import Callable from copy import deepcopy -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import http import time from unittest.mock import AsyncMock, patch @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -384,14 +384,45 @@ async def test_add_and_remove_work_area( values: dict[str, MowerAttributes], ) -> None: """Test adding a work area in runtime.""" + websocket_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] current_entites_start = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) - values[TEST_MOWER_ID].work_area_names.append("new work area") - values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) - values[TEST_MOWER_ID].work_areas.update( + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + new_task = Calendar( + start=dt_time(hour=11), + duration=timedelta(60), + monday=True, + tuesday=True, + wednesday=True, + thursday=True, + friday=True, + saturday=True, + sunday=True, + work_area_id=1, + ) + websocket_values[TEST_MOWER_ID].calendar.tasks.append(new_task) + poll_values = deepcopy(websocket_values) + poll_values[TEST_MOWER_ID].work_area_names.append("new work area") + poll_values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) + poll_values[TEST_MOWER_ID].work_areas.update( { 1: WorkArea( name="new work area", @@ -404,10 +435,15 @@ async def test_add_and_remove_work_area( ) } ) - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) + mock_automower_client.get_status.return_value = poll_values + + callback_holder["cb"](websocket_values) await hass.async_block_till_done() + assert mock_automower_client.get_status.called + + state = hass.states.get("sensor.test_mower_1_new_work_area_progress") + assert state is not None + assert state.state == "12" current_entites_after_addition = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) @@ -419,15 +455,15 @@ async def test_add_and_remove_work_area( + ADDITIONAL_SWITCH_ENTITIES ) - values[TEST_MOWER_ID].work_area_names.remove("new work area") - del values[TEST_MOWER_ID].work_area_dict[1] - del values[TEST_MOWER_ID].work_areas[1] - values[TEST_MOWER_ID].work_area_names.remove("Front lawn") - del values[TEST_MOWER_ID].work_area_dict[123456] - del values[TEST_MOWER_ID].work_areas[123456] - del values[TEST_MOWER_ID].calendar.tasks[:2] - values[TEST_MOWER_ID].mower.work_area_id = 654321 - mock_automower_client.get_status.return_value = values + poll_values[TEST_MOWER_ID].work_area_names.remove("new work area") + del poll_values[TEST_MOWER_ID].work_area_dict[1] + del poll_values[TEST_MOWER_ID].work_areas[1] + poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") + del poll_values[TEST_MOWER_ID].work_area_dict[123456] + del poll_values[TEST_MOWER_ID].work_areas[123456] + del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 + mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From f680e992ff3f9dae2f0a23feeb1a76072b60014e Mon Sep 17 00:00:00 2001 From: kanshurichard Date: Tue, 15 Jul 2025 01:07:50 +0800 Subject: [PATCH 0197/1118] Add support for Broadlink A2 air quality sensor (#142203) Co-authored-by: Joostlek --- homeassistant/components/broadlink/const.py | 1 + homeassistant/components/broadlink/sensor.py | 19 +++++++++++++++++++ homeassistant/components/broadlink/updater.py | 11 +++++++++++ 3 files changed, 31 insertions(+) diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c9b17128b79..602a3693b7b 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = { Platform.SELECT: {"HYS"}, Platform.SENSOR: { "A1", + "A2", "MP1S", "RM4MINI", "RM4PRO", diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index e7d420f0c0e..5323a08d227 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="air_quality", device_class=SensorDeviceClass.AQI, ), + SensorEntityDescription( + key="pm10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm2_5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 8e0a521e182..7c1644fff54 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager """Return an update manager for a given Broadlink device.""" update_managers: dict[str, type[BroadlinkUpdateManager]] = { "A1": BroadlinkA1UpdateManager, + "A2": BroadlinkA2UpdateManager, "BG1": BroadlinkBG1UpdateManager, "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, @@ -118,6 +119,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]): return await self.device.async_request(self.device.api.check_sensors_raw) +class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]): + """Manages updates for Broadlink A2 devices.""" + + SCAN_INTERVAL = timedelta(seconds=10) + + async def async_fetch_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.check_sensors_raw) + + class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]): """Manages updates for Broadlink MP1 devices.""" From 92bb1f255169d5363a8324d4f1ce50e99614a214 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:00:21 +0200 Subject: [PATCH 0198/1118] Do not add utility_meter config entry to source device (#148735) --- .../components/utility_meter/__init__.py | 42 +++- .../components/utility_meter/config_flow.py | 1 + .../components/utility_meter/select.py | 12 +- .../components/utility_meter/sensor.py | 19 +- .../snapshots/test_diagnostics.ambr | 2 +- .../utility_meter/test_config_flow.py | 48 +++- tests/components/utility_meter/test_init.py | 233 ++++++++++++++++-- tests/components/utility_meter/test_sensor.py | 2 + 8 files changed, 312 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 64fa3342c08..8a388058b19 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,7 +21,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -199,6 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Utility Meter from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR] ) @@ -225,20 +229,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -286,13 +286,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True hass.config_entries.async_update_entry(config_entry, options=new, version=2) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 2: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the utility_meter config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index db7cea6ecf2..933a04accba 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -130,6 +130,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" VERSION = 2 + MINOR_VERSION = 2 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 0c818525c8d..280a1fd7b1a 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,8 +8,8 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -33,7 +33,7 @@ async def async_setup_entry( unique_id = config_entry.entry_id - device_info = async_device_info_to_link_from_entity( + device = async_entity_id_to_device( hass, config_entry.options[CONF_SOURCE_SENSOR], ) @@ -42,7 +42,7 @@ async def async_setup_entry( name=name, tariffs=tariffs, unique_id=unique_id, - device_info=device_info, + device=device, ) async_add_entities([tariff_select]) @@ -91,14 +91,14 @@ class TariffSelect(SelectEntity, RestoreEntity): *, yaml_slug: str | None = None, unique_id: str | None = None, - device_info: DeviceInfo | None = None, + device: DeviceEntry | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name if yaml_slug: # Backwards compatibility with YAML configuration entries self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = device self._current_tariff: str | None = None self._tariffs = tariffs self._attr_should_poll = False diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index d424692ac95..457b02c2b50 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -39,7 +39,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -129,11 +129,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) @@ -154,6 +149,7 @@ async def async_setup_entry( if not tariffs: # Add single sensor, not gated by a tariff selector meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -166,7 +162,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=None, unique_id=entry_id, - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -175,6 +170,7 @@ async def async_setup_entry( # Add sensors for each tariff for tariff in tariffs: meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -187,7 +183,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=tariff, unique_id=f"{entry_id}_{tariff}", - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -259,6 +254,7 @@ async def async_setup_platform( CONF_SENSOR_ALWAYS_AVAILABLE ] meter_sensor = UtilityMeterSensor( + hass, cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, meter_offset=conf_meter_offset, @@ -359,6 +355,7 @@ class UtilityMeterSensor(RestoreSensor): def __init__( self, + hass, *, cron_pattern, delta_values, @@ -374,11 +371,13 @@ class UtilityMeterSensor(RestoreSensor): unique_id, sensor_always_available, suggested_entity_id=None, - device_info=None, ): """Initialize the Utility Meter sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index ef235bba99d..024fd1aaa7b 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'discovery_keys': dict({ }), 'domain': 'utility_meter', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ 'cycle': 'monthly', 'delta_values': False, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 01fd80acc0e..0aa73d6d123 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -403,11 +403,19 @@ async def test_change_device_source( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 1 (current) device registry + # Confirm that the configuration entry has not been added to the source entity 1 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_1.device_id # Change configuration options to use source entity 2 (with a linked device) and reload the integration previous_entity_source = source_entity_1 @@ -427,17 +435,25 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 1 (previous) device registry + # Confirm that the configuration entry is not in the source entity 1 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in to the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id # Change configuration options to use source entity 3 (without a device) and reload the integration previous_entity_source = source_entity_2 @@ -457,12 +473,20 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 2 (previous) device registry + # Confirm that the configuration entry has is not in the source entity 2 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries + # Check that the entities are no longer linked to a device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + # Confirm that there is no device with the helper configuration entry assert ( dr.async_entries_for_config_entry( @@ -489,8 +513,16 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ea4af741e19..ec7fdd1db87 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.utility_meter import ( ) from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -29,7 +29,7 @@ from homeassistant.const import ( Platform, UnitOfEnergy, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component @@ -108,6 +108,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -601,7 +602,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(utility_meter_config_entry.entry_id) @@ -616,7 +617,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 @pytest.mark.parametrize( @@ -642,6 +643,81 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], ) -> None: """Test the utility_meter config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -667,7 +743,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor's config entry from the device, this removes the # source sensor @@ -682,7 +758,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -734,7 +818,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor from the device with patch( @@ -747,7 +831,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -805,7 +897,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries @@ -820,11 +912,19 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is moved to the other device + # Check that the entities are linked to the other device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -874,7 +974,7 @@ async def test_async_handle_source_entity_new_entity_id( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Change the source entity's entity ID with patch( @@ -890,9 +990,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the utility_meter config entry is updated with the new entity ID assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -900,3 +1000,108 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events for entity_events in events.values(): assert entity_events == [] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_migration_2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, + tariffs: list[str], + expected_entities: set[str], +) -> None: + """Test migration from v2.1 removes utility_meter config entry from device.""" + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=2, + minor_version=1, + ) + utility_meter_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + assert utility_meter_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entities are linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + # Check that the entities are linked to the other device + entities = set() + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + entities.add(utility_meter_entity.entity_id) + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + assert entities == expected_entities + + assert utility_meter_config_entry.version == 2 + assert utility_meter_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test", + "tariffs": [], + }, + title="My utility meter", + version=3, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2de2ee553b3..f684cdb16a0 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1888,10 +1888,12 @@ async def test_bad_offset(hass: HomeAssistant) -> None: def test_calculate_adjustment_invalid_new_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: """Test that calculate_adjustment method returns None if the new state is invalid.""" mock_sensor = UtilityMeterSensor( + hass, cron_pattern=None, delta_values=False, meter_offset=DEFAULT_OFFSET, From 57f89dd606fa22dadfccee7a37f951d7f8f8df84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:00:49 +0200 Subject: [PATCH 0199/1118] Do not add trend config entry to source device (#148733) --- homeassistant/components/trend/__init__.py | 45 ++++- .../components/trend/binary_sensor.py | 20 +-- homeassistant/components/trend/config_flow.py | 2 + tests/components/trend/test_init.py | 156 ++++++++++++++++-- 4 files changed, 199 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 086ac818c8e..332ec9455eb 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) PLATFORMS = [Platform.BINARY_SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trend from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -37,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -53,6 +62,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the trend config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an Trend options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 30058bb056c..5a7046c2125 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -33,8 +33,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -114,6 +114,7 @@ async def async_setup_platform( for sensor_name, sensor_config in config[CONF_SENSORS].items(): entities.append( SensorTrend( + hass, name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), entity_id=sensor_config[CONF_ENTITY_ID], attribute=sensor_config.get(CONF_ATTRIBUTE), @@ -140,14 +141,10 @@ async def async_setup_entry( ) -> None: """Set up trend sensor from config entry.""" - device_info = async_device_info_to_link_from_entity( - hass, - entry.options[CONF_ENTITY_ID], - ) - async_add_entities( [ SensorTrend( + hass, name=entry.title, entity_id=entry.options[CONF_ENTITY_ID], attribute=entry.options.get(CONF_ATTRIBUTE), @@ -159,7 +156,6 @@ async def async_setup_entry( min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), unique_id=entry.entry_id, - device_info=device_info, ) ] ) @@ -174,6 +170,8 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, + *, name: str, entity_id: str, attribute: str | None, @@ -185,7 +183,6 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): unique_id: str | None = None, device_class: BinarySensorDeviceClass | None = None, sensor_entity_id: str | None = None, - device_info: dr.DeviceInfo | None = None, ) -> None: """Initialize the sensor.""" self._entity_id = entity_id @@ -199,7 +196,10 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._attr_name = name self._attr_device_class = device_class self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) if sensor_entity_id: self.entity_id = sensor_entity_id diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 756b9536d19..3bb06ae3042 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -101,6 +101,8 @@ CONFIG_SCHEMA = vol.Schema( class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Trend.""" + MINOR_VERSION = 2 + config_flow = { "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), "settings": SchemaFlowFormStep(get_base_options_schema), diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 4ff6213d082..22700376b26 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import trend from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +200,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(trend_config_entry.entry_id) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +226,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the trend config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -241,7 +289,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -258,7 +306,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -285,7 +336,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -300,7 +351,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the entity is no longer linked to the source device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id is None + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -333,7 +388,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert trend_config_entry.entry_id not in sensor_device_2.config_entries @@ -350,11 +405,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is moved to the other device + # Check that the entity is linked to the other device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_device_2.id + + # Check that the trend config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert trend_config_entry.entry_id in sensor_device_2.config_entries + assert trend_config_entry.entry_id not in sensor_device_2.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -379,7 +438,7 @@ async def test_async_handle_source_entity_new_entity_id( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -397,12 +456,83 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the trend config entry is updated with the new entity ID assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes trend config entry from device.""" + + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=1, + minor_version=1, + ) + trend_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=trend_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + assert trend_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + assert trend_config_entry.version == 1 + assert trend_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.test", + "invert": False, + }, + title="My trend", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 7df0016fab91cd81e850856e25a4ce8a2b423bfb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:05:20 +0200 Subject: [PATCH 0200/1118] Do not add threshold config entry to source device (#148732) --- .../components/threshold/__init__.py | 50 ++++- .../components/threshold/binary_sensor.py | 42 +++-- .../components/threshold/config_flow.py | 17 +- tests/components/threshold/test_init.py | 177 ++++++++++++++++-- 4 files changed, 237 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 9460a50db80..56d51f4f1e0 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1,5 +1,7 @@ """The threshold component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,12 +9,18 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Min/Max from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_ENTITY_ID] ), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the threshold config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3227f030812..88fd2784f96 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -102,11 +101,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_ENTITY_ID] ) - device_info = async_device_info_to_link_from_entity( - hass, - entity_id, - ) - hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] name = config_entry.title @@ -116,14 +110,14 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - entity_id, - name, - lower, - upper, - hysteresis, - device_class, - unique_id, - device_info=device_info, + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=unique_id, ) ] ) @@ -146,7 +140,14 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - entity_id, name, lower, upper, hysteresis, device_class, None + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=None, ) ], ) @@ -171,6 +172,8 @@ class ThresholdSensor(BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + *, entity_id: str, name: str, lower: float | None, @@ -178,12 +181,15 @@ class ThresholdSensor(BinarySensorEntity): hysteresis: float, device_class: BinarySensorDeviceClass | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None self._attr_unique_id = unique_id - self._attr_device_info = device_info + if entity_id: # Guard against empty entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) self._entity_id = entity_id self._attr_name = name if lower is not None: diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 24f58333782..29f4a0986c1 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -80,6 +80,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Threshold.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -131,13 +133,14 @@ def ws_start_preview( ) preview_entity = ThresholdSensor( - entity_id, - name, - msg["user_input"].get(CONF_LOWER), - msg["user_input"].get(CONF_UPPER), - msg["user_input"].get(CONF_HYSTERESIS), - None, - None, + hass, + entity_id=entity_id, + name=name, + lower=msg["user_input"].get(CONF_LOWER), + upper=msg["user_input"].get(CONF_UPPER), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + device_class=None, + unique_id=None, ) preview_entity.hass = hass diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 599612ce0b7..fed35bc6502 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import threshold from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -174,6 +175,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries run1_entry = _create_mock_entity("sensor", "initial") run2_entry = _create_mock_entity("sensor", "changed") + assert run1_entry.device_id != run2_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -186,23 +188,27 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: "name": "My threshold", "upper": None, }, - title="My integration", + title="My threshold", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(run1_entry) + assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run1_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(run1_entry) - assert config_entry.entry_id in _get_device_config_entries(run2_entry) + assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run2_entry.device_id async def test_device_cleaning( @@ -273,7 +279,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(threshold_config_entry.entry_id) @@ -288,7 +294,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -299,6 +305,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the threshold config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -315,7 +369,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -332,7 +386,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -359,7 +417,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -374,7 +432,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -407,7 +469,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert threshold_config_entry.entry_id not in sensor_device_2.config_entries @@ -424,11 +486,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is moved to the other device + # Check that the entity is linked to the other device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert threshold_config_entry.entry_id in sensor_device_2.config_entries + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -453,7 +519,7 @@ async def test_async_handle_source_entity_new_entity_id( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -471,12 +537,87 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the threshold config entry is updated with the new entity ID assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes threshold config entry from device.""" + + threshold_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=1, + minor_version=1, + ) + threshold_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + assert threshold_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + assert threshold_config_entry.version == 1 + assert threshold_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test", + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 254f76635787f3f14b792c9c8bb9552aad0fdc16 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:05:34 +0200 Subject: [PATCH 0201/1118] Do not add history_stats config entry to source device (#148729) --- .../components/history_stats/__init__.py | 44 ++++- .../components/history_stats/config_flow.py | 9 +- .../components/history_stats/sensor.py | 30 +++- tests/components/history_stats/test_init.py | 169 ++++++++++++++++-- 4 files changed, 228 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index a3565f9ed77..efddabd180c 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE @@ -11,7 +12,10 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -20,6 +24,8 @@ from .data import HistoryStats type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry @@ -47,6 +53,7 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -67,6 +74,7 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -83,6 +91,40 @@ async def async_setup_entry( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the history_stats config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry ) -> bool: diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 996c7ba0d0c..750180bf3f6 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -124,6 +124,8 @@ OPTIONS_FLOW = { class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -229,7 +231,12 @@ async def ws_start_preview( coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) await coordinator.async_refresh() preview_entity = HistoryStatsSensor( - hass, coordinator, sensor_type, name, None, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=None, + source_entity_id=entity_id, ) preview_entity.hass = hass diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 780bff14eb1..0cfe82e09fb 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -113,7 +113,16 @@ async def async_setup_platform( if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception async_add_entities( - [HistoryStatsSensor(hass, coordinator, sensor_type, name, unique_id, entity_id)] + [ + HistoryStatsSensor( + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=unique_id, + source_entity_id=entity_id, + ) + ] ) @@ -130,7 +139,12 @@ async def async_setup_entry( async_add_entities( [ HistoryStatsSensor( - hass, coordinator, sensor_type, entry.title, entry.entry_id, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=entry.title, + unique_id=entry.entry_id, + source_entity_id=entity_id, ) ] ) @@ -176,6 +190,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def __init__( self, hass: HomeAssistant, + *, coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, @@ -190,10 +205,11 @@ class HistoryStatsSensor(HistoryStatsSensorBase): self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type self._attr_unique_id = unique_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index cb3350f497f..7f81fe6625f 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -18,7 +18,7 @@ from homeassistant.components.history_stats.const import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -92,6 +92,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -181,7 +182,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(history_stats_config_entry.entry_id) @@ -196,9 +197,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures("recorder_mock") @@ -210,6 +209,56 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the history_stats config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -226,7 +275,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -243,7 +292,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -273,7 +325,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -288,7 +340,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the entity is no longer linked to the source device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id is None + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -322,7 +378,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries @@ -339,11 +395,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is moved to the other device + # Check that the entity is linked to the other device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -369,7 +429,7 @@ async def test_async_handle_source_entity_new_entity_id( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -387,12 +447,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the history_stats config entry is updated with the new entity ID assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes history_stats config entry from device.""" + + history_stats_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=1, + minor_version=1, + ) + history_stats_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + assert history_stats_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + assert history_stats_config_entry.version == 1 + assert history_stats_config_entry.minor_version == 2 + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test", + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 1a1e9e9f57c4e53dafbc212a8b62046abb5c4583 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:15:39 +0200 Subject: [PATCH 0202/1118] Add test for combining state change and state report listeners (#148721) --- tests/helpers/test_event.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 465d1b1778b..c875522b943 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4946,6 +4946,37 @@ async def test_async_track_state_report_event(hass: HomeAssistant) -> None: unsub() +async def test_async_track_state_report_change_event(hass: HomeAssistant) -> None: + """Test listen for both state change and state report events.""" + tracker_called: dict[str, list[str]] = {"light.bowl": [], "light.top": []} + + @ha.callback + def on_state_change(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + @ha.callback + def on_state_report(event: Event[EventStateReportedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + async_track_state_change_event(hass, ["light.bowl", "light.top"], on_state_change) + async_track_state_report_event(hass, ["light.bowl", "light.top"], on_state_report) + entity_ids = ["light.bowl", "light.top"] + state_sequence = ["on", "on", "off", "off"] + for state in state_sequence: + for entity_id in entity_ids: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + # The out-of-order is a result of state change listeners scheduled with + # loop.call_soon, whereas state report listeners are called immediately. + assert tracker_called == { + "light.bowl": ["on", "off", "on", "off"], + "light.top": ["on", "off", "on", "off"], + } + + async def test_async_track_template_no_hass_deprecated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From e35f7b12f1fd6ff7da6a65e20dcd0e63955da724 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:11 +0200 Subject: [PATCH 0203/1118] Do not add generic_hygrostat config entry to source device (#148727) --- .../components/generic_hygrostat/__init__.py | 50 +++- .../generic_hygrostat/config_flow.py | 2 + .../generic_hygrostat/humidifier.py | 37 +-- .../components/generic_hygrostat/test_init.py | 264 +++++++++++++++--- 4 files changed, 289 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index a12994c1a75..d907f863988 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -1,5 +1,7 @@ """The generic_hygrostat component.""" +import logging + import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass @@ -16,7 +18,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Generic Hygrostat component.""" @@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -101,23 +109,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the humidifer, # but not the humidity sensor because the generic_hygrostat adds itself to the # humidifier's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HUMIDIFIER] ), source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], - source_entity_removed=source_entity_removed, ) ) @@ -148,6 +152,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_hygrostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HUMIDIFIER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 7c35b0e9317..449fa49b713 100644 --- a/homeassistant/components/generic_hygrostat/config_flow.py +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -92,6 +92,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 6e699745279..7746346d010 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -42,7 +42,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -145,22 +145,22 @@ async def _async_setup_config( [ GenericHygrostat( hass, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, + name=name, + switch_entity_id=switch_entity_id, + sensor_entity_id=sensor_entity_id, + min_humidity=min_humidity, + max_humidity=max_humidity, + target_humidity=target_humidity, + device_class=device_class, + min_cycle_duration=min_cycle_duration, + dry_tolerance=dry_tolerance, + wet_tolerance=wet_tolerance, + keep_alive=keep_alive, + initial_state=initial_state, + away_humidity=away_humidity, + away_fixed=away_fixed, + sensor_stale_duration=sensor_stale_duration, + unique_id=unique_id, ) ] ) @@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, switch_entity_id: str, sensor_entity_id: str, @@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, switch_entity_id, ) diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 254d4da5806..64db21eab8c 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_hygrostat from homeassistant.components.generic_hygrostat import DOMAIN from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -119,10 +119,20 @@ def generic_hygrostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -201,7 +211,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -216,9 +226,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -229,8 +237,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -239,7 +251,83 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_hygrostat config entry is removed when the source entity is removed.""" @@ -263,9 +351,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -284,6 +370,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -305,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -315,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -333,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -352,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +479,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +489,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +511,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries @@ -427,13 +530,18 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_hygrostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -452,10 +560,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -466,7 +574,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -483,9 +590,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -505,11 +610,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_hygrostat config entry is updated with the new entity ID assert generic_hygrostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -518,3 +621,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + + generic_hygrostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=1, + minor_version=1, + ) + generic_hygrostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_hygrostat_config_entry.version == 1 + assert generic_hygrostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": "switch.test", + "name": "My generic hygrostat", + "target_sensor": "sensor.test", + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 3ae9ea3f19fe087982ed004eeef65acbd8bd3ddb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:21 +0200 Subject: [PATCH 0204/1118] Do not add generic_thermostat config entry to source device (#148728) --- .../components/generic_thermostat/__init__.py | 50 +++- .../components/generic_thermostat/climate.py | 39 +-- .../generic_thermostat/config_flow.py | 2 + .../generic_thermostat/test_init.py | 265 +++++++++++++++--- 4 files changed, 292 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 3e2af8598de..98cd9a02baa 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,5 +1,7 @@ """The generic_thermostat component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,14 +10,20 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -28,23 +36,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HEATER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the heater, but # not the temperature sensor because the generic_hygrostat adds itself to the # heater's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HEATER] ), source_entity_id_or_uuid=entry.options[CONF_HEATER], - source_entity_removed=source_entity_removed, ) ) @@ -75,6 +79,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_thermostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HEATER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 185040f02c9..76fcc4acdde 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -48,7 +48,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -182,23 +182,23 @@ async def _async_setup_config( [ GenericThermostat( hass, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, + name=name, + heater_entity_id=heater_entity_id, + sensor_entity_id=sensor_entity_id, + min_temp=min_temp, + max_temp=max_temp, + target_temp=target_temp, + ac_mode=ac_mode, + min_cycle_duration=min_cycle_duration, + cold_tolerance=cold_tolerance, + hot_tolerance=hot_tolerance, + keep_alive=keep_alive, + initial_hvac_mode=initial_hvac_mode, + presets=presets, + precision=precision, + target_temperature_step=target_temperature_step, + unit=unit, + unique_id=unique_id, ) ] ) @@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, heater_entity_id: str, sensor_entity_id: str, @@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._attr_name = name self.heater_entity_id = heater_entity_id self.sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, heater_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 1fbeaefde6b..b69106597d1 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -100,6 +100,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 9131e3ffdd4..ceca7ecc444 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_thermostat from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -117,10 +117,20 @@ def generic_thermostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +209,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -214,9 +224,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -227,8 +235,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -237,7 +249,84 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_thermostat config entry is removed when the source entity is removed.""" @@ -261,9 +350,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -282,6 +369,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -304,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -314,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -332,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -351,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +480,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +490,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +512,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( generic_thermostat_config_entry.entry_id not in source_device_2.config_entries @@ -429,13 +533,20 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_thermostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( - generic_thermostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) # Check that the generic_thermostat config entry is not removed assert ( @@ -455,10 +566,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "heater"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "heater"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -469,7 +580,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -486,9 +596,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -508,11 +616,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_thermostat config entry is updated with the new entity ID assert generic_thermostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed assert ( @@ -522,3 +628,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_thermostat config entry from device.""" + + generic_thermostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=1, + minor_version=1, + ) + generic_thermostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_thermostat_config_entry.version == 1 + assert generic_thermostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": "switch.test", + "target_sensor": "sensor.test", + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From c27a67db8248ff78b96cad237c77e77e0b7c3bb4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:41 +0200 Subject: [PATCH 0205/1118] Do not add integration config entry to source device (#148730) --- .../components/integration/__init__.py | 50 ++++- .../components/integration/config_flow.py | 2 + .../components/integration/sensor.py | 18 +- tests/components/integration/test_init.py | 179 ++++++++++++++++-- 4 files changed, 216 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 0a64ce7140f..82f44578aed 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_SOURCE_SENSOR +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Integration from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -29,20 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the integration config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" # Remove device link for entry, the source device may have changed. diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 28cd280f7f8..329abdbea87 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -147,6 +147,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Integration.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index df5342111a7..25181ac6149 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,8 +40,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -246,11 +245,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None @@ -265,6 +259,7 @@ async def async_setup_entry( round_digits = int(round_digits) integral = IntegrationSensor( + hass, integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=round_digits, @@ -272,7 +267,6 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -287,6 +281,7 @@ async def async_setup_platform( ) -> None: """Set up the integration sensor.""" integral = IntegrationSensor( + hass, integration_method=config[CONF_METHOD], name=config.get(CONF_NAME), round_digits=config.get(CONF_ROUND_DIGITS), @@ -308,6 +303,7 @@ class IntegrationSensor(RestoreSensor): def __init__( self, + hass: HomeAssistant, *, integration_method: str, name: str | None, @@ -317,7 +313,6 @@ class IntegrationSensor(RestoreSensor): unit_prefix: str | None, unit_time: UnitOfTime, max_sub_interval: timedelta | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -335,7 +330,10 @@ class IntegrationSensor(RestoreSensor): self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._max_sub_interval: timedelta | None = ( None # disable time based integration if max_sub_interval is None or max_sub_interval.total_seconds() == 0 diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 0ce3297a2ff..50243551d37 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import integration from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -83,6 +83,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -176,6 +177,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries input_entry = _create_mock_entity("sensor", "input") valid_entry = _create_mock_entity("sensor", "valid") + assert input_entry.device_id != valid_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -193,17 +195,21 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(input_entry) + assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == input_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(input_entry) - assert config_entry.entry_id in _get_device_config_entries(valid_entry) + assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == valid_entry.device_id async def test_device_cleaning( @@ -276,7 +282,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(integration_config_entry.entry_id) @@ -291,7 +297,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -302,6 +308,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the integration config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -318,7 +372,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -335,7 +389,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +420,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -377,7 +435,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -410,7 +472,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert integration_config_entry.entry_id not in sensor_device_2.config_entries @@ -427,11 +489,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is moved to the other device + # Check that the entity is linked to the other device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert integration_config_entry.entry_id in sensor_device_2.config_entries + assert integration_config_entry.entry_id not in sensor_device_2.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -456,7 +522,7 @@ async def test_async_handle_source_entity_new_entity_id( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -474,12 +540,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the integration config entry is updated with the new entity ID assert integration_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes integration config entry from device.""" + + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=1, + minor_version=1, + ) + integration_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=integration_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + assert integration_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + assert integration_config_entry.version == 1 + assert integration_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": "sensor.test", + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 124931b2eebc0dde424962d70dea74afe81ac254 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Jul 2025 20:23:43 +0200 Subject: [PATCH 0206/1118] TTS to always stream when available (#148695) Co-authored-by: Michael Hansen --- homeassistant/components/tts/__init__.py | 16 +++- .../snapshots/test_pipeline.ambr | 2 +- .../assist_pipeline/test_pipeline.py | 6 +- tests/components/tts/test_init.py | 2 +- .../wyoming/snapshots/test_tts.ambr | 80 +++++++++++++++++++ tests/components/wyoming/test_tts.py | 10 ++- 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index c8e6e0f67fb..cf9099448df 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -382,7 +382,7 @@ async def _async_convert_audio( assert process.stderr stderr_data = await process.stderr.read() _LOGGER.error(stderr_data.decode()) - raise RuntimeError( + raise HomeAssistantError( f"Unexpected error while running ffmpeg with arguments: {command}. " "See log for details." ) @@ -976,7 +976,7 @@ class SpeechManager: if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider) or isinstance(message_or_stream, str): + if isinstance(engine_instance, Provider): if isinstance(message_or_stream, str): message = message_or_stream else: @@ -996,8 +996,18 @@ class SpeechManager: data_gen = make_data_generator(data) else: + if isinstance(message_or_stream, str): + + async def gen_stream() -> AsyncGenerator[str]: + yield message_or_stream + + stream = gen_stream() + + else: + stream = message_or_stream + tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_or_stream) + TTSAudioRequest(language, options, stream) ) extension = tts_result.extension data_gen = tts_result.data_gen diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 7f760d069e6..95415ddb902 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_chat_log_tts_streaming[to_stream_deltas0-0-] +# name: test_chat_log_tts_streaming[to_stream_deltas0-1-hello, how are you?] list([ dict({ 'data': dict({ diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 3a4895440dc..5bc7b86c38c 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1550,9 +1550,9 @@ async def test_pipeline_language_used_instead_of_conversation_language( "?", ], ), - # We are not streaming, so 0 chunks via streaming method - 0, - "", + # We always stream when possible, so 1 chunk via streaming method + 1, + "hello, how are you?", ), # Size above STREAM_RESPONSE_CHUNKS ( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 22fb10209b0..db42da5de0e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1835,7 +1835,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): + with pytest.raises(HomeAssistantError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 53cc02eaacf..67c9b24160c 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -1,6 +1,19 @@ # serializer version: 1 # name: test_get_tts_audio list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -8,10 +21,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -19,10 +51,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats.1 list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -30,6 +81,12 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_streaming @@ -71,6 +128,23 @@ # --- # name: test_voice_speaker list([ + dict({ + 'data': dict({ + 'voice': dict({ + 'name': 'voice1', + 'speaker': 'speaker1', + }), + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -82,5 +156,11 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 3374328f411..efcf464eebb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -52,6 +52,7 @@ async def test_get_tts_audio( # Verify audio audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -77,7 +78,10 @@ async def test_get_tts_audio( assert wav_file.getframerate() == 16000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio + + # nframes = 0 due to streaming + assert len(data) == len(audio) + 44 # WAVE header is 44 bytes + assert data[44:] == audio assert mock_client.written == snapshot @@ -88,6 +92,7 @@ async def test_get_tts_audio_different_formats( """Test changing preferred audio format.""" audio = bytes(16000 * 2 * 1) # one second audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -123,6 +128,7 @@ async def test_get_tts_audio_different_formats( # MP3 is the default audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -167,6 +173,7 @@ async def test_get_tts_audio_audio_oserror( """Test get audio and error raising.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -197,6 +204,7 @@ async def test_voice_speaker( """Test using a different voice and speaker.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] From 8421ca7802d16b4b3fed9ede6d2608230bde0b49 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:28:27 -0400 Subject: [PATCH 0207/1118] Add assumed optimistic state to template select (#148513) --- homeassistant/components/template/select.py | 150 ++++++++++++-------- tests/components/template/test_select.py | 118 ++++++++++++--- 2 files changed, 190 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 8c05e8e2592..256955e70a8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -45,7 +46,7 @@ DEFAULT_OPTIMISTIC = False SELECT_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, vol.Required(ATTR_OPTIONS): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -116,49 +117,22 @@ async def async_setup_entry( async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) -class TemplateSelect(TemplateEntity, SelectEntity): - """Representation of a template select.""" +class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): + """Representation of a template select features.""" - _attr_should_poll = False + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + self._template = config.get(CONF_STATE) - def __init__( - self, - hass: HomeAssistant, - config: dict[str, Any], - unique_id: str | None, - ) -> None: - """Initialize the select.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None - self._value_template = config[CONF_STATE] - # 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) + + self._attr_assumed_state = self._optimistic = ( + self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC) + ) self._attr_options = [] self._attr_current_option = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - self.add_template_attribute( - "_attr_current_option", - self._value_template, - validator=cv.string, - none_on_template_error=True, - ) - self.add_template_attribute( - "_attr_options", - self._options_template, - validator=vol.All(cv.ensure_list, [cv.string]), - none_on_template_error=True, - ) - super()._async_setup_templates() async def async_select_option(self, option: str) -> None: """Change the selected option.""" @@ -173,11 +147,56 @@ class TemplateSelect(TemplateEntity, SelectEntity): ) -class TriggerSelectEntity(TriggerEntity, SelectEntity): +class TemplateSelect(TemplateEntity, AbstractTemplateSelect): + """Representation of a template select.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the select.""" + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + AbstractTemplateSelect.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: + self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: + self.add_template_attribute( + "_attr_current_option", + self._template, + validator=cv.string, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_options", + self._options_template, + validator=vol.All(cv.ensure_list, [cv.string]), + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect): """Select entity based on trigger data.""" domain = SELECT_DOMAIN - extra_template_keys = (CONF_STATE,) extra_template_keys_complex = (ATTR_OPTIONS,) def __init__( @@ -187,7 +206,12 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSelect.__init__(self, config) + + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + # 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( @@ -197,24 +221,26 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): DOMAIN, ) - @property - def current_option(self) -> str | None: - """Return the currently selected option.""" - return self._rendered.get(CONF_STATE) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def options(self) -> list[str]: - """Return the list of available options.""" - return self._rendered.get(ATTR_OPTIONS, []) - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_current_option = option + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + if (options := self._rendered.get(ATTR_OPTIONS)) is not None: + self._attr_options = vol.All(cv.ensure_list, [cv.string])(options) + write_ha_state = True + + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_current_option = cv.string(state) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5e29993f0f6..6971d41750d 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_ID, CONF_ICON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -43,11 +44,15 @@ _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" TEST_STATE_ENTITY_ID = "select.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + "entity_id": [ + _OPTION_INPUT_SELECT, + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY_ID, + ], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -201,20 +206,6 @@ async def test_multiple_configs(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "select": { - "select_option": {"service": "script.select_option"}, - "options": "{{ ['a', 'b'] }}", - } - } - }, - ) - with assert_setup_component(0, "select"): assert await setup.async_setup_component( hass, @@ -559,3 +550,98 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "a" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + "state": "{{ states('select.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_select") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "test") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" From 1753baf1860eb1cc49111f56171727d55750e9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 14 Jul 2025 19:28:53 +0100 Subject: [PATCH 0208/1118] Add method to track entity state changes from target selectors (#148086) Co-authored-by: Erik Montnemery --- homeassistant/helpers/target.py | 115 ++++++++++++++++++- tests/helpers/test_target.py | 194 +++++++++++++++++++++++++++++++- 2 files changed, 303 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index c16819235b9..239d1e66336 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Callable import dataclasses +import logging from logging import Logger -from typing import TypeGuard +from typing import Any, TypeGuard from homeassistant.const import ( ATTR_AREA_ID, @@ -14,7 +16,14 @@ from homeassistant.const import ( ATTR_LABEL_ID, ENTITY_MATCH_NONE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from . import ( area_registry as ar, @@ -25,8 +34,11 @@ from . import ( group, label_registry as lr, ) +from .event import async_track_state_change_event from .typing import ConfigType +_LOGGER = logging.getLogger(__name__) + def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" @@ -238,3 +250,102 @@ def async_extract_referenced_entity_ids( ) return selected + + +class TargetStateChangeTracker: + """Helper class to manage state change tracking for targets.""" + + def __init__( + self, + hass: HomeAssistant, + selector_data: TargetSelectorData, + action: Callable[[Event[EventStateChangedData]], Any], + ) -> None: + """Initialize the state change tracker.""" + self._hass = hass + self._selector_data = selector_data + self._action = action + + self._state_change_unsub: CALLBACK_TYPE | None = None + self._registry_unsubs: list[CALLBACK_TYPE] = [] + + def async_setup(self) -> Callable[[], None]: + """Set up the state change tracking.""" + self._setup_registry_listeners() + self._track_entities_state_change() + return self._unsubscribe + + def _track_entities_state_change(self) -> None: + """Set up state change tracking for currently selected entities.""" + selected = async_extract_referenced_entity_ids( + self._hass, self._selector_data, expand_group=False + ) + + @callback + def state_change_listener(event: Event[EventStateChangedData]) -> None: + """Handle state change events.""" + if ( + event.data["entity_id"] in selected.referenced + or event.data["entity_id"] in selected.indirectly_referenced + ): + self._action(event) + + tracked_entities = selected.referenced.union(selected.indirectly_referenced) + + _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, state_change_listener + ) + + def _setup_registry_listeners(self) -> None: + """Set up listeners for registry changes that require resubscription.""" + + @callback + def resubscribe_state_change_event(event: Event[Any] | None = None) -> None: + """Resubscribe to state change events when registry changes.""" + if self._state_change_unsub: + self._state_change_unsub() + self._track_entities_state_change() + + # Subscribe to registry updates that can change the entities to track: + # - Entity registry: entity added/removed; entity labels changed; entity area changed. + # - Device registry: device labels changed; device area changed. + # - Area registry: area floor changed. + # + # We don't track other registries (like floor or label registries) because their + # changes don't affect which entities are tracked. + self._registry_unsubs = [ + self._hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event + ), + ] + + def _unsubscribe(self) -> None: + """Unsubscribe from all events.""" + for registry_unsub in self._registry_unsubs: + registry_unsub() + self._registry_unsubs.clear() + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None + + +def async_track_target_selector_state_change_event( + hass: HomeAssistant, + target_selector_config: ConfigType, + action: Callable[[Event[EventStateChangedData]], Any], +) -> CALLBACK_TYPE: + """Track state changes for entities referenced directly or indirectly in a target selector.""" + selector_data = TargetSelectorData(target_selector_config) + if not selector_data.has_any_selector: + raise HomeAssistantError( + f"Target selector {target_selector_config} does not have any selectors defined" + ) + tracker = TargetStateChangeTracker(hass, selector_data, action) + return tracker.async_setup() diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index ca38f316d89..c87a320e378 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,9 +2,6 @@ import pytest -# TODO(abmantis): is this import needed? -# To prevent circular import when running just this file -import homeassistant.components # noqa: F401 from homeassistant.components.group import Group from homeassistant.const import ( ATTR_AREA_ID, @@ -17,17 +14,21 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, + label_registry as lr, target, ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import ( + MockConfigEntry, RegistryEntryWithDefaults, mock_area_registry, mock_device_registry, @@ -457,3 +458,188 @@ async def test_extract_referenced_entity_ids( ) == expected_selected ) + + +async def test_async_track_target_selector_state_change_event_empty_selector( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_target_selector_state_change_event with empty selector.""" + + @callback + def state_change_callback(event): + """Handle state change events.""" + + with pytest.raises(HomeAssistantError) as excinfo: + target.async_track_target_selector_state_change_event( + hass, {}, state_change_callback + ) + assert str(excinfo.value) == ( + "Target selector {} does not have any selectors defined" + ) + + +async def test_async_track_target_selector_state_change_event( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with multiple targets.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def state_change_callback(event: Event[EventStateChangedData]): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, last_state) + await hass.async_block_till_done() + + assert len(events) == len(entities_to_assert_change) + entities_seen = set() + for event in events: + entities_seen.add(event.data["entity_id"]) + assert event.data["new_state"].state == last_state + assert entities_seen == set(entities_to_assert_change) + events.clear() + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device_reg = dr.async_get(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_1")}, + ) + + untargeted_device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "area_device")}, + ) + + entity_reg = er.async_get(hass) + device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light", + device_id=device_entry.id, + ).entity_id + + untargeted_device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="area_device_light", + device_id=untargeted_device_entry.id, + ).entity_id + + untargeted_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="untargeted_light", + ).entity_id + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, device_entity] + await set_states_and_check_events(targeted_entities, []) + + label = lr.async_get(hass).async_create("Test Label").name + area = ar.async_get(hass).async_create("Test Area").id + floor = fr.async_get(hass).async_create("Test Floor").floor_id + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_DEVICE_ID: device_entry.id, + ATTR_AREA_ID: area, + ATTR_FLOOR_ID: floor, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback + ) + + # Test directly targeted entity and device + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Add new entity to the targeted device -> should trigger on state change + device_entity_2 = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light_2", + device_id=device_entry.id, + ).entity_id + + targeted_entities = [targeted_entity, device_entity, device_entity_2] + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Test untargeted entity -> should not trigger + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add label to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, labels={label}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove label from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, labels={}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove area from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted device -> should trigger on state change + device_reg.async_update_device(untargeted_device_entry.id, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], + [*targeted_entities, untargeted_device_entity], + ) + + # Remove area from untargeted device -> should not trigger anymore + device_reg.async_update_device(untargeted_device_entry.id, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], targeted_entities + ) + + # Set the untargeted area on the untargeted entity -> should not trigger + untracked_area = ar.async_get(hass).async_create("Untargeted Area").id + entity_reg.async_update_entity(untargeted_entity, area_id=untracked_area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Set targeted floor on the untargeted area -> should trigger now + ar.async_get(hass).async_update(untracked_area, floor_id=floor) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], + [*targeted_entities, untargeted_entity], + ) + + # Remove untargeted area from targeted floor -> should not trigger anymore + ar.async_get(hass).async_update(untracked_area, floor_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # After unsubscribing, changes should not trigger + unsub() + await set_states_and_check_events(targeted_entities, []) From c9356868f730a5cbb97aba7ef6c729a52168cace Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:29:57 +0200 Subject: [PATCH 0209/1118] Add add-on discovery flow to pyLoad integration (#148494) --- .../components/pyload/config_flow.py | 58 ++++++ homeassistant/components/pyload/strings.json | 12 ++ tests/components/pyload/conftest.py | 16 ++ tests/components/pyload/test_config_flow.py | 191 +++++++++++++++++- 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 50d354d345d..1a1481f9c26 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -97,6 +98,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,3 +214,58 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for pyLoad add-on. + + This flow is triggered by the discovery component. + """ + url = URL(discovery_info.config[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + discovery_info.config[CONF_URL] = url + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + + data = {**self._hassio_discovery.config, CONF_VERIFY_SSL: False} + + if user_input is not None: + data.update(user_input) + + try: + await validate_input(self.hass, data) + except (CannotConnect, ParserError): + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders=self._hassio_discovery.config, + ) + return self.async_create_entry(title=self._hassio_discovery.slug, data=data) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=REAUTH_SCHEMA, suggested_values=data + ), + description_placeholders=self._hassio_discovery.config, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 9414f7f7bb8..66435fd2806 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -39,6 +39,18 @@ "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]" } + }, + "hassio_confirm": { + "title": "pyLoad via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the pyLoad service provided by the add-on: {addon}?", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" + } } }, "error": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 9b410a5fdd6..72fabfa3de1 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry @@ -39,6 +40,21 @@ NEW_INPUT = { } +ADDON_DISCOVERY_INFO = { + "addon": "pyLoad-ng", + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", +} + +ADDON_SERVICE_INFO = HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="pyLoad-ng Addon", + slug="p539df76c_pyload-ng", + uuid="1234", +) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 492e4a4b652..1eafbd2eb66 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,18 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT +from .conftest import ( + ADDON_DISCOVERY_INFO, + ADDON_SERVICE_INFO, + NEW_INPUT, + REAUTH_INPUT, + USER_INPUT, +) from tests.common import MockConfigEntry @@ -245,3 +252,183 @@ async def test_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == USER_INPUT assert len(hass.config_entries.async_entries()) == 1 + + +async def test_hassio_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = InvalidAuth + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery. Abort with confirm only.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_hassio_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_data_update( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update entry from discovery data.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://539df76c-pyload-ng:8000/" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 0729b3a2f1f9516281ac8659a7a52ce6ad441938 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:53:53 +0200 Subject: [PATCH 0210/1118] Change hass.data storage to runtime.data for Squeezebox (#146482) --- homeassistant/components/squeezebox/__init__.py | 13 ++++--------- homeassistant/components/squeezebox/const.py | 2 -- homeassistant/components/squeezebox/media_player.py | 8 +++----- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 8bd0e2fca52..c6cb04b5ffb 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,7 +1,7 @@ """The Squeezebox integration.""" from asyncio import timeout -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from http import HTTPStatus import logging @@ -37,8 +37,6 @@ from .const import ( DISCOVERY_INTERVAL, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -73,6 +71,7 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + known_player_ids: set[str] = field(default_factory=set) type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData] @@ -187,16 +186,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) - # set up player discovery - known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) - known_players = known_servers.setdefault(lms.uuid, {}).setdefault(KNOWN_PLAYERS, []) - async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" async def _discovered_player(player: Player) -> None: """Handle a (re)discovered player.""" - if player.player_id in known_players: + if player.player_id in entry.runtime_data.known_player_ids: await player.async_update() async_dispatcher_send( hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected @@ -207,7 +202,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() - known_players.append(player.player_id) + entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 9d78605aee1..091ef4d1bbd 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -4,8 +4,6 @@ CONF_HTTPS = "https" DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 -KNOWN_PLAYERS = "known_players" -KNOWN_SERVERS = "known_servers" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 SERVER_MANUFACTURER = "https://lyrion.org/" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 8cf945cd7e9..f37faa4e115 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -60,8 +60,6 @@ from .const import ( DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -316,9 +314,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - known_servers = self.hass.data[DOMAIN][KNOWN_SERVERS] - known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS] - known_players.remove(self.coordinator.player.player_id) + self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.player.player_id + ) @property def volume_level(self) -> float | None: From ed4a23d104711e24adfe9133743ca990feaf6556 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:57:00 +0200 Subject: [PATCH 0211/1118] Override connect method in RecorderPool (#148490) --- homeassistant/components/recorder/pool.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d8d7ddb832a..2ee41ba2038 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.pool import ( ConnectionPoolEntry, NullPool, + PoolProxiedConnection, SingletonThreadPool, StaticPool, ) @@ -119,6 +120,12 @@ class RecorderPool(SingletonThreadPool, NullPool): ) return NullPool._create_connection(self) # noqa: SLF001 + def connect(self) -> PoolProxiedConnection: + """Return a connection from the pool.""" + if threading.get_ident() in self.recorder_and_worker_thread_ids: + return super().connect() + return NullPool.connect(self) + class MutexPool(StaticPool): """A pool which prevents concurrent accesses from multiple threads. From 1ef07544d57b2009204357791a1d95b5f5ec86db Mon Sep 17 00:00:00 2001 From: Stephan Traub Date: Mon, 14 Jul 2025 21:07:47 +0200 Subject: [PATCH 0212/1118] Fix for ignored devices issue #137114 (#146562) --- homeassistant/components/wiz/config_flow.py | 2 +- tests/components/wiz/test_config_flow.py | 43 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 92b25389450..a676c77688d 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -124,7 +124,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_HOST: device.ip_address}, ) - current_unique_ids = self._async_current_ids() + current_unique_ids = self._async_current_ids(include_ignore=False) current_hosts = { entry.data[CONF_HOST] for entry in self._async_current_entries(include_ignore=False) diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index ddf4a4f452a..946eb032f8e 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -572,3 +572,46 @@ async def test_discovered_during_onboarding(hass: HomeAssistant, source, data) - } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_replace_ignored_device(hass: HomeAssistant) -> None: + """Test we can replace an ignored device via discovery.""" + # Add ignored entry to simulate previously ignored device + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + # Patch discovery to find the same ignored device + with _patch_discovery(), _patch_wizlight(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + # Proceed with selecting the device — previously ignored + with ( + _patch_wizlight(), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.wiz.async_setup", + return_value=True, + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: FAKE_MAC} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 9068a09620643b193c3b671b3170dc1a63da901c Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:08:16 +0200 Subject: [PATCH 0213/1118] Add Stookwijzer forecast service (#138392) Co-authored-by: Joost Lekkerkerker --- .../components/stookwijzer/__init__.py | 16 +++- homeassistant/components/stookwijzer/const.py | 3 + .../components/stookwijzer/icons.json | 7 ++ .../components/stookwijzer/services.py | 76 +++++++++++++++++++ .../components/stookwijzer/services.yaml | 7 ++ .../components/stookwijzer/strings.json | 18 +++++ tests/components/stookwijzer/conftest.py | 10 +-- .../stookwijzer/snapshots/test_services.ambr | 27 +++++++ tests/components/stookwijzer/test_services.py | 72 ++++++++++++++++++ 9 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/stookwijzer/icons.json create mode 100644 homeassistant/components/stookwijzer/services.py create mode 100644 homeassistant/components/stookwijzer/services.yaml create mode 100644 tests/components/stookwijzer/snapshots/test_services.ambr create mode 100644 tests/components/stookwijzer/test_services.py diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index 9adfc09de0e..e51f3d76c7c 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -8,13 +8,27 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator +from .services import setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Stookwijzer component.""" + setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1b0be86d375..7b4c28540fc 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -5,3 +5,6 @@ from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/icons.json b/homeassistant/components/stookwijzer/icons.json new file mode 100644 index 00000000000..19fda370796 --- /dev/null +++ b/homeassistant/components/stookwijzer/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_forecast": { + "service": "mdi:clock-plus-outline" + } + } +} diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py new file mode 100644 index 00000000000..e8c12717a21 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.py @@ -0,0 +1,76 @@ +"""Define services for the Stookwijzer integration.""" + +from typing import Required, TypedDict, cast + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .coordinator import StookwijzerConfigEntry + +SERVICE_GET_FORECAST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + } +) + + +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + +def async_get_entry( + hass: HomeAssistant, config_entry_id: str +) -> StookwijzerConfigEntry: + """Get the Overseerr config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(StookwijzerConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Stookwijzer integration.""" + + async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None: + """Get the forecast from API endpoint.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + return cast( + ServiceResponse, + { + "forecast": cast( + list[Forecast], await client.async_get_forecast() or [] + ), + }, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECAST, + async_get_forecast, + schema=SERVICE_GET_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/stookwijzer/services.yaml b/homeassistant/components/stookwijzer/services.yaml new file mode 100644 index 00000000000..49e1f7b2927 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.yaml @@ -0,0 +1,7 @@ +get_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: stookwijzer diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index a028f1f19c5..160387ed8aa 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -27,6 +27,18 @@ } } }, + "services": { + "get_forecast": { + "name": "Get forecast", + "description": "Retrieves the advice forecast from Stookwijzer.", + "fields": { + "config_entry_id": { + "name": "Stookwijzer instance", + "description": "The Stookwijzer instance to get the forecast from." + } + } + } + }, "issues": { "location_migration_failed": { "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", @@ -36,6 +48,12 @@ "exceptions": { "no_data_received": { "message": "No data received from Stookwijzer." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } } } diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index dd7f2a7bbc3..0f127ba767a 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,26 +1,18 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator -from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.components.stookwijzer.services import Forecast from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -class Forecast(TypedDict): - """Typed Stookwijzer forecast dict.""" - - datetime: Required[str] - advice: str | None - final: bool | None - - @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/stookwijzer/snapshots/test_services.ambr b/tests/components/stookwijzer/snapshots/test_services.ambr new file mode 100644 index 00000000000..d5124219d32 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_services.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_service_get_forecast + dict({ + 'forecast': tuple( + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ), + }) +# --- diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py new file mode 100644 index 00000000000..f60730a290d --- /dev/null +++ b/tests/components/stookwijzer/test_services.py @@ -0,0 +1,72 @@ +"""Tests for the Stookwijzer services.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.stookwijzer.const import ( + ATTR_CONFIG_ENTRY_ID, + DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_service_get_forecast( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Stookwijzer forecast service.""" + + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_entry_not_loaded( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when entry is not loaded.""" + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_integration_not_found( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when integration not in registry.""" + with pytest.raises( + ServiceValidationError, match='Integration "stookwijzer" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"}, + blocking=True, + return_response=True, + ) From d42d270fb233ee8f2af6fcbabe2b7bff1f10a1c3 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Mon, 14 Jul 2025 21:16:26 +0200 Subject: [PATCH 0214/1118] Bump Huum to version 0.8.0 (#148763) --- homeassistant/components/huum/climate.py | 12 ++---------- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 84173260d04..bbeb50a2b72 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -112,16 +112,8 @@ class HuumDevice(ClimateEntity): await self._turn_on(temperature) async def async_update(self) -> None: - """Get the latest status data. - - We get the latest status first from the status endpoints of the sauna. - If that data does not include the temperature, that means that the sauna - is off, we then call the off command which will in turn return the temperature. - This is a workaround for getting the temperature as the Huum API does not - return the target temperature of a sauna that is off, even if it can have - a target temperature at that time. - """ - self._status = await self._huum_handler.status_from_status_or_stop() + """Get the latest status data.""" + self._status = await self._huum_handler.status() if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: self._target_temperature = self._status.target_temperature diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 38562e1a072..82b863e4e42 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.12"] + "requirements": ["huum==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0f903370b4..0a5313d6978 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee0dc556a1..332a6c61863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1032,7 +1032,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 From c08c4024097d5208165b2900abf3a661638c853f Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:16:29 +0200 Subject: [PATCH 0215/1118] Add switches for HmIPW-DRI16, HmIPW-DRI32, HmIPW-DRS4, HmIPW-DRS8 (#148571) --- homeassistant/components/homematicip_cloud/switch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index ca591adbf5e..5da2989f93f 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -18,6 +18,9 @@ from homematicip.device import ( PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, SwitchMeasuring, + WiredInput32, + WiredInputSwitch6, + WiredSwitch4, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup @@ -51,6 +54,7 @@ async def async_setup_entry( elif isinstance( device, ( + WiredSwitch4, WiredSwitch8, OpenCollector8Module, BrandSwitch2, @@ -60,6 +64,8 @@ async def async_setup_entry( MotionDetectorSwitchOutdoor, DinRailSwitch, DinRailSwitch4, + WiredInput32, + WiredInputSwitch6, ), ): channel_indices = [ From 9e3a78b7efa954bcf1eac7d9ef6a77b1040f4237 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Jul 2025 21:18:12 +0200 Subject: [PATCH 0216/1118] Bump pySmartThings to 3.2.8 (#148761) --- 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 2c4974a6567..35354570f23 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.2.7"] + "requirements": ["pysmartthings==3.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a5313d6978..52b7555b6fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 332a6c61863..d8be5f73588 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 From 80eb4fb2f6a80eacd7a5c9c8dad31d07961a25f9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:24:32 +0200 Subject: [PATCH 0217/1118] Replace asyncio.iscoroutinefunction (#148738) --- homeassistant/components/knx/websocket.py | 4 ++-- homeassistant/core.py | 2 +- homeassistant/helpers/condition.py | 4 ++-- homeassistant/helpers/frame.py | 4 ++-- homeassistant/helpers/http.py | 4 ++-- homeassistant/helpers/service.py | 3 ++- homeassistant/helpers/singleton.py | 3 ++- homeassistant/helpers/trigger.py | 3 ++- homeassistant/util/__init__.py | 4 ++-- tests/components/music_assistant/common.py | 4 ++-- tests/util/test_logging.py | 5 +++-- 11 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 31c5e8297e0..b40dc2246b8 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -2,9 +2,9 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from functools import wraps +import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel @@ -116,7 +116,7 @@ def provide_knx( "KNX integration not loaded.", ) - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @wraps(func) async def with_knx( diff --git a/homeassistant/core.py b/homeassistant/core.py index 469acd5dae8..8ffabf56171 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -384,7 +384,7 @@ def get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: while isinstance(check_target, functools.partial): check_target = check_target.func - if asyncio.iscoroutinefunction(check_target): + if inspect.iscoroutinefunction(check_target): return HassJobType.Coroutinefunction if is_callback(check_target): return HassJobType.Callback diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 37ff9b22ff7..3c6120f523f 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,12 +3,12 @@ from __future__ import annotations import abc -import asyncio from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft +import inspect import logging import re import sys @@ -359,7 +359,7 @@ async def async_from_config( while isinstance(check_factory, ft.partial): check_factory = check_factory.func - if asyncio.iscoroutinefunction(check_factory): + if inspect.iscoroutinefunction(check_factory): return cast(ConditionCheckerType, await factory(hass, config)) return cast(ConditionCheckerType, factory(config)) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 8f0741b5166..2d9b368254a 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import enum import functools +import inspect import linecache import logging import sys @@ -397,7 +397,7 @@ def _report_usage_no_integration( def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 68daf5c7939..e890a8ed087 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus +import inspect import logging from typing import Any, Final @@ -45,7 +45,7 @@ def request_handler_factory( hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) + is_coroutinefunction = inspect.iscoroutinefunction(handler) assert is_coroutinefunction or is_callback(handler), ( "Handler should be a coroutine or a callback." ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 1d4dac10c27..3186c211eaa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -7,6 +7,7 @@ from collections.abc import Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial +import inspect import logging from types import ModuleType from typing import TYPE_CHECKING, Any, TypedDict, cast, override @@ -997,7 +998,7 @@ def verify_domain_control( service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" - if not asyncio.iscoroutinefunction(service_handler): + if not inspect.iscoroutinefunction(service_handler): raise HomeAssistantError("Can only decorate async functions.") async def check_permissions(call: ServiceCall) -> Any: diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 075fc50b49a..dac2e5832f6 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import functools +import inspect from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant @@ -47,7 +48,7 @@ def singleton[_S, _T, _U]( def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 57ee6b99029..46b3d883865 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -8,6 +8,7 @@ from collections import defaultdict from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools +import inspect import logging from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -407,7 +408,7 @@ def _trigger_action_wrapper( check_func = check_func.func wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] - if asyncio.iscoroutinefunction(check_func): + if inspect.iscoroutinefunction(check_func): async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 19515fd7945..17a4a86f106 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView, Mapping from datetime import datetime, timedelta from functools import wraps +import inspect import random import re import string @@ -125,7 +125,7 @@ class Throttle: def __call__(self, method: Callable) -> Callable: """Caller for the throttle.""" # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): + if inspect.iscoroutinefunction(method): async def throttled_value() -> None: """Stand-in function for when real func is being throttled.""" diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index a98ae82fbe1..072b1ece1a1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +import inspect from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -191,7 +191,7 @@ async def trigger_subscription_callback( object_id=object_id, data=data, ) - if asyncio.iscoroutinefunction(cb_func): + if inspect.iscoroutinefunction(cb_func): await cb_func(event) else: cb_func(event) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index ba473ee0c58..406952881bc 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -2,6 +2,7 @@ import asyncio from functools import partial +import inspect import logging import queue from unittest.mock import patch @@ -102,7 +103,7 @@ def test_catch_log_exception() -> None: async def async_meth(): pass - assert asyncio.iscoroutinefunction( + assert inspect.iscoroutinefunction( logging_util.catch_log_exception(partial(async_meth), lambda: None) ) @@ -120,7 +121,7 @@ def test_catch_log_exception() -> None: wrapped = logging_util.catch_log_exception(partial(sync_meth), lambda: None) assert not is_callback(wrapped) - assert not asyncio.iscoroutinefunction(wrapped) + assert not inspect.iscoroutinefunction(wrapped) @pytest.mark.no_fail_on_log_exception From 5ec9c4e6e31c46bb63af2a48992f134709383627 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:24:50 +0200 Subject: [PATCH 0218/1118] Add PS Vita support to PlayStation Network integration (#148186) --- .../playstation_network/__init__.py | 14 +- .../playstation_network/binary_sensor.py | 2 +- .../playstation_network/config_flow.py | 5 +- .../components/playstation_network/const.py | 5 +- .../playstation_network/coordinator.py | 80 +++++++-- .../playstation_network/diagnostics.py | 16 +- .../components/playstation_network/entity.py | 8 +- .../components/playstation_network/helpers.py | 52 +++++- .../playstation_network/media_player.py | 48 ++++-- .../components/playstation_network/sensor.py | 2 +- .../playstation_network/conftest.py | 43 ++++- .../snapshots/test_diagnostics.ambr | 9 + .../snapshots/test_media_player.ambr | 162 ++++++++++++++++++ .../playstation_network/test_init.py | 158 ++++++++++++++++- .../playstation_network/test_media_player.py | 70 ++++++++ 15 files changed, 614 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index feb598a646a..e5b98d00726 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -6,7 +6,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_NPSSO -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkRuntimeData, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ @@ -23,9 +28,12 @@ async def async_setup_entry( psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) - coordinator = PlaystationNetworkCoordinator(hass, psn, entry) + coordinator = PlaystationNetworkUserDataCoordinator(hass, psn, entry) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) + + entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index fcecd1d1ee1..453cfb37347 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index b4a4a9374fa..0e69abf1080 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -10,7 +10,6 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from psnawp_api.models.user import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol @@ -42,7 +41,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): else: psn = PlaystationNetwork(self.hass, npsso) try: - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except PSNAWPNotFoundError: @@ -98,7 +97,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): try: npsso = parse_npsso_token(user_input[CONF_NPSSO]) psn = PlaystationNetwork(self.hass, npsso) - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 77b43af3b73..f4c5c7a3e5b 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -8,9 +8,10 @@ DOMAIN = "playstation_network" CONF_NPSSO: Final = "npsso" SUPPORTED_PLATFORMS = { - PlatformType.PS5, - PlatformType.PS4, + PlatformType.PS_VITA, PlatformType.PS3, + PlatformType.PS4, + PlatformType.PS5, PlatformType.PSPC, } diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 69cc95d1d49..a9f49f7f7bb 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging @@ -10,6 +12,7 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,13 +24,22 @@ from .helpers import PlaystationNetwork, PlaystationNetworkData _LOGGER = logging.getLogger(__name__) -type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkRuntimeData] -class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): - """Data update coordinator for PSN.""" +@dataclass +class PlaystationNetworkRuntimeData: + """Dataclass holding PSN runtime data.""" + + user_data: PlaystationNetworkUserDataCoordinator + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + + +class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry + _update_inverval: timedelta def __init__( self, @@ -41,16 +53,43 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData name=DOMAIN, logger=_LOGGER, config_entry=config_entry, - update_interval=timedelta(seconds=30), + update_interval=self._update_interval, ) self.psn = psn + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" + + async def _async_update_data(self) -> _DataT: + """Get the latest data from the PSN.""" + try: + return await self.update_data() + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + + +class PlaystationNetworkUserDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Data update coordinator for PSN.""" + + _update_interval = timedelta(seconds=30) + async def _async_setup(self) -> None: """Set up the coordinator.""" try: - await self.psn.get_user() + await self.psn.async_setup() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -62,17 +101,22 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData translation_key="update_failed", ) from error - async def _async_update_data(self) -> PlaystationNetworkData: + async def update_data(self) -> PlaystationNetworkData: """Get the latest data from the PSN.""" - try: - return await self.psn.get_data() - except PSNAWPAuthenticationError as error: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="not_ready", - ) from error - except (PSNAWPServerError, PSNAWPClientError) as error: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - ) from error + return await self.psn.get_data() + + +class PlaystationNetworkTrophyTitlesCoordinator( + PlayStationNetworkBaseCoordinator[list[TrophyTitle]] +): + """Trophy titles data update coordinator for PSN.""" + + _update_interval = timedelta(days=1) + + async def update_data(self) -> list[TrophyTitle]: + """Update trophy titles data.""" + self.psn.trophy_titles = await self.hass.async_add_executor_job( + lambda: list(self.psn.user.trophy_titles()) + ) + await self.config_entry.runtime_data.user_data.async_request_refresh() + return self.psn.trophy_titles diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 8332572177d..7b5c762db12 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -10,7 +10,7 @@ from psnawp_api.models.trophies import PlatformType from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkConfigEntry TO_REDACT = { "account_id", @@ -27,12 +27,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: PlaystationNetworkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PlaystationNetworkCoordinator = entry.runtime_data + coordinator = entry.runtime_data.user_data return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ), + ) } @@ -46,10 +46,12 @@ def _serialize_platform_types(data: Any) -> Any: for platform, record in data.items() } if isinstance(data, set): - return [ - record.value if isinstance(record, PlatformType) else record - for record in data - ] + return sorted( + [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + ) if isinstance(data, PlatformType): return data.value return data diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 54f5fd5db70..660c77dc30f 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,17 +7,19 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkUserDataCoordinator -class PlaystationNetworkServiceEntity(CoordinatorEntity[PlaystationNetworkCoordinator]): +class PlaystationNetworkServiceEntity( + CoordinatorEntity[PlaystationNetworkUserDataCoordinator] +): """Common entity class for PlayStationNetwork Service entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: PlaystationNetworkCoordinator, + coordinator: PlaystationNetworkUserDataCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 9c7dac29a81..debe7a338e2 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -8,7 +8,7 @@ from typing import Any from psnawp_api import PSNAWP from psnawp_api.models.client import Client -from psnawp_api.models.trophies import PlatformType, TrophySummary +from psnawp_api.models.trophies import PlatformType, TrophySummary, TrophyTitle from psnawp_api.models.user import User from pyrate_limiter import Duration, Rate @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from .const import SUPPORTED_PLATFORMS -LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4, PlatformType.PS_VITA} @dataclass @@ -52,10 +52,22 @@ class PlaystationNetwork: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) self.psn = PSNAWP(npsso, rate_limit=rate) - self.client: Client | None = None + self.client: Client self.hass = hass self.user: User self.legacy_profile: dict[str, Any] | None = None + self.trophy_titles: list[TrophyTitle] = [] + self._title_icon_urls: dict[str, str] = {} + + def _setup(self) -> None: + """Setup PSN.""" + self.user = self.psn.user(online_id="me") + self.client = self.psn.me() + self.trophy_titles = list(self.user.trophy_titles()) + + async def async_setup(self) -> None: + """Setup PSN.""" + await self.hass.async_add_executor_job(self._setup) async def get_user(self) -> User: """Get the user object from the PlayStation Network.""" @@ -68,9 +80,6 @@ class PlaystationNetwork: """Bundle api calls to retrieve data from the PlayStation Network.""" data = PlaystationNetworkData() - if not self.client: - self.client = self.psn.me() - data.registered_platforms = { PlatformType(device["deviceType"]) for device in self.client.get_account_devices() @@ -123,7 +132,7 @@ class PlaystationNetwork: presence = self.legacy_profile["profile"].get("presences", []) if (game_title_info := presence[0] if presence else {}) and game_title_info[ "onlineStatus" - ] == "online": + ] != "offline": platform = PlatformType(game_title_info["platform"]) if platform is PlatformType.PS4: @@ -135,6 +144,10 @@ class PlaystationNetwork: account_id="me", np_communication_id="", ).get_title_icon_url() + elif platform is PlatformType.PS_VITA and game_title_info.get( + "npTitleId" + ): + media_image_url = self.get_psvita_title_icon_url(game_title_info) else: media_image_url = None @@ -147,3 +160,28 @@ class PlaystationNetwork: status=game_title_info["onlineStatus"], ) return data + + def get_psvita_title_icon_url(self, game_title_info: dict[str, Any]) -> str | None: + """Look up title_icon_url from trophy titles data.""" + + if url := self._title_icon_urls.get(game_title_info["npTitleId"]): + return url + + url = next( + ( + title.title_icon_url + for title in self.trophy_titles + if game_title_info["titleName"] + == normalize_title(title.title_name or "") + and next(iter(title.title_platform)) == PlatformType.PS_VITA + ), + None, + ) + if url is not None: + self._title_icon_urls[game_title_info["npTitleId"]] = url + return url + + +def normalize_title(name: str) -> str: + """Normalize trophy title.""" + return name.removesuffix("Trophies").removesuffix("Trophy Set").strip() diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 3e55e565460..0a9b8fe6162 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -17,13 +17,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from . import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .const import DOMAIN, SUPPORTED_PLATFORMS _LOGGER = logging.getLogger(__name__) PLATFORM_MAP = { + PlatformType.PS_VITA: "PlayStation Vita", PlatformType.PS5: "PlayStation 5", PlatformType.PS4: "PlayStation 4", PlatformType.PS3: "PlayStation 3", @@ -38,7 +43,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Media Player Entity Setup.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data + trophy_titles = config_entry.runtime_data.trophy_titles devices_added: set[PlatformType] = set() device_reg = dr.async_get(hass) entities = [] @@ -50,10 +56,12 @@ async def async_setup_entry( if not SUPPORTED_PLATFORMS - devices_added: remove_listener() - new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + new_platforms = ( + set(coordinator.data.active_sessions.keys()) & SUPPORTED_PLATFORMS + ) - devices_added if new_platforms: async_add_entities( - PsnMediaPlayerEntity(coordinator, platform_type) + PsnMediaPlayerEntity(coordinator, platform_type, trophy_titles) for platform_type in new_platforms ) devices_added |= new_platforms @@ -64,7 +72,7 @@ async def async_setup_entry( (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") } ): - entities.append(PsnMediaPlayerEntity(coordinator, platform)) + entities.append(PsnMediaPlayerEntity(coordinator, platform, trophy_titles)) devices_added.add(platform) if entities: async_add_entities(entities) @@ -74,7 +82,7 @@ async def async_setup_entry( class PsnMediaPlayerEntity( - CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity + CoordinatorEntity[PlaystationNetworkUserDataCoordinator], MediaPlayerEntity ): """Media player entity representing currently playing game.""" @@ -86,7 +94,10 @@ class PsnMediaPlayerEntity( _attr_name = None def __init__( - self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + self, + coordinator: PlaystationNetworkUserDataCoordinator, + platform: PlatformType, + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator, ) -> None: """Initialize PSN MediaPlayer.""" super().__init__(coordinator) @@ -101,15 +112,21 @@ class PsnMediaPlayerEntity( model=PLATFORM_MAP[platform], via_device=(DOMAIN, coordinator.config_entry.unique_id), ) + self.trophy_titles = trophy_titles @property def state(self) -> MediaPlayerState: """Media Player state getter.""" session = self.coordinator.data.active_sessions.get(self.key) - if session and session.status == "online": - if session.title_id is not None: - return MediaPlayerState.PLAYING - return MediaPlayerState.ON + if session: + if session.status == "online": + return ( + MediaPlayerState.PLAYING + if session.title_id is not None + else MediaPlayerState.ON + ) + if session.status == "standby": + return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property @@ -129,3 +146,12 @@ class PsnMediaPlayerEntity( """Media image url getter.""" session = self.coordinator.data.active_sessions.get(self.key) return session.media_image_url if session else None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + if self.key is PlatformType.PS_VITA: + self.async_on_remove( + self.trophy_titles.async_add_listener(self._handle_coordinator_update) + ) diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index cfd81fe4033..b17b4c04ab7 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -131,7 +131,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 431a30ba7f7..5f6f3436699 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -1,9 +1,15 @@ """Common fixtures for the Playstation Network tests.""" from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch -from psnawp_api.models.trophies import TrophySet, TrophySummary +from psnawp_api.models.trophies import ( + PlatformType, + TrophySet, + TrophySummary, + TrophyTitle, +) import pytest from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN @@ -83,13 +89,14 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.user.return_value = mock_user client.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PSVITA"}, { "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", "deviceType": "PS5", "activationType": "PRIMARY", "activationDate": "2021-01-14T18:00:00.000Z", "accountDeviceVector": "abcdefghijklmnopqrstuv", - } + }, ] client.me.return_value.trophy_summary.return_value = TrophySummary( PSN_ID, 1079, 19, 10, TrophySet(14450, 8722, 11754, 1398) @@ -118,7 +125,37 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: "isOfficiallyVerified": False, "isMe": True, } - + client.user.return_value.trophy_titles.return_value = [ + TrophyTitle( + np_service_name="trophy", + np_communication_id="NPWR03134_00", + trophy_set_version="01.03", + title_name="Assassin's Creed® III Liberation", + title_detail="Assassin's Creed® III Liberation", + title_icon_url="https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG", + title_platform=frozenset({PlatformType.PS_VITA}), + has_trophy_groups=False, + progress=28, + hidden_flag=False, + earned_trophies=TrophySet(bronze=4, silver=8, gold=0, platinum=0), + defined_trophies=TrophySet(bronze=22, silver=21, gold=1, platinum=1), + last_updated_datetime=datetime(2016, 10, 6, 18, 5, 8, tzinfo=UTC), + np_title_id=None, + ) + ] + client.me.return_value.get_profile_legacy.return_value = { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index f320eea4b7c..ebf8d9e927f 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -12,6 +12,14 @@ 'title_id': 'PPSA07784_00', 'title_name': 'STAR WARS Jedi: Survivor™', }), + 'PSVITA': dict({ + 'format': 'PSVITA', + 'media_image_url': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'platform': 'PSVITA', + 'status': 'online', + 'title_id': 'PCSB00074_00', + 'title_name': "Assassin's Creed® III Liberation", + }), }), 'availability': 'availableToPlay', 'presence': dict({ @@ -61,6 +69,7 @@ }), 'registered_platforms': list([ 'PS5', + 'PSVITA', ]), 'trophy_summary': dict({ 'account_id': '**REDACTED**', diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index a42522592e4..69024c2326f 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -1,4 +1,166 @@ # serializer version: 1 +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standby', + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d', + 'friendly_name': 'PlayStation Vita', + 'media_content_id': 'PCSB00074_00', + 'media_content_type': , + 'media_title': "Assassin's Creed® III Liberation", + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform[PS4_idle][media_player.playstation_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index 09fbe4b0de4..c1f2691d623 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -1,7 +1,9 @@ """Tests for PlayStation Network.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from psnawp_api.core import ( PSNAWPAuthenticationError, PSNAWPClientError, @@ -11,10 +13,13 @@ from psnawp_api.core import ( import pytest from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.components.playstation_network.coordinator import ( + PlaystationNetworkRuntimeData, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -107,3 +112,154 @@ async def test_coordinator_update_auth_failed( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == config_entry.entry_id + + +async def test_trophy_title_coordinator( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator updates when PS Vita is registered.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + +async def test_trophy_title_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator starts reauth on authentication error.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = ( + PSNAWPAuthenticationError + ) + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_trophy_title_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator update failed.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = exception + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data + assert runtime_data.trophy_titles.last_update_success is False + + +async def test_trophy_title_coordinator_doesnt_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator does not update if no PS Vita is registered.""" + + mock_psnawpapi.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PS5"}, + {"deviceType": "PS3"}, + ] + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + +async def test_trophy_title_coordinator_play_new_game( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we play a new game and get a title image on next trophy titles update.""" + + _tmp = mock_psnawpapi.user.return_value.trophy_titles.return_value + mock_psnawpapi.user.return_value.trophy_titles.return_value = [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("media_player.playstation_vita")) + assert state.attributes.get("entity_picture") is None + + mock_psnawpapi.user.return_value.trophy_titles.return_value = _tmp + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + assert (state := hass.states.get("media_player.playstation_vita")) + assert ( + state.attributes["entity_picture"] + == "https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG" + ) diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py index f503a5ec297..53bf6436c73 100644 --- a/tests/components/playstation_network/test_media_player.py +++ b/tests/components/playstation_network/test_media_player.py @@ -114,6 +114,76 @@ async def test_platform( """Test setup of the PlayStation Network media_player platform.""" mock_psnawpapi.user().get_presence.return_value = presence_payload + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "profile": { + "presences": [ + { + "onlineStatus": "standby", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_media_player_psvita( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player for PlayStation Vita.""" + + mock_psnawpapi.user().get_presence.return_value = { + "basicPresence": { + "availability": "unavailable", + "primaryPlatformInfo": {"onlineStatus": "offline", "platform": ""}, + } + } + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = presence_payload config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 37ae476c67cddd842c93493f8acc63ef45740e6b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 14 Jul 2025 21:26:03 +0200 Subject: [PATCH 0219/1118] Add Zeroconf support for bsblan integration (#146137) Co-authored-by: Joost Lekkerkerker --- .../components/bsblan/config_flow.py | 142 ++++- homeassistant/components/bsblan/manifest.json | 8 +- homeassistant/components/bsblan/sensor.py | 2 + homeassistant/components/bsblan/strings.json | 20 +- homeassistant/generated/zeroconf.py | 4 + tests/components/bsblan/test_config_flow.py | 539 ++++++++++++++++-- 6 files changed, 658 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index a1d7d6d403a..6abfe57a4ae 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN @@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str - port: int - mac: str - passkey: str | None = None - username: str | None = None - password: str | None = None + def __init__(self) -> None: + """Initialize BSBLan flow.""" + self.host: str | None = None + self.port: int = DEFAULT_PORT + self.mac: str | None = None + self.passkey: str | None = None + self.username: str | None = None + self.password: str | None = None + self._auth_required = True async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,9 +45,111 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) + return await self._validate_and_create() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle Zeroconf discovery.""" + + self.host = str(discovery_info.ip_address) + self.port = discovery_info.port or DEFAULT_PORT + + # Get MAC from properties + self.mac = discovery_info.properties.get("mac") + + # If MAC was found in zeroconf, use it immediately + if self.mac: + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + else: + # MAC not available from zeroconf - check for existing host/port first + self._async_abort_entries_match( + {CONF_HOST: self.host, CONF_PORT: self.port} + ) + + # Try to get device info without authentication to minimize discovery popup + config = BSBLANConfig(host=self.host, port=self.port) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config, session) + try: + device = await bsblan.device() + except BSBLANError: + # Device requires authentication - proceed to discovery confirm + self.mac = None + else: + self.mac = device.MAC + + # Got MAC without auth - set unique ID and check for existing device + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + # No auth needed, so we can proceed to a confirmation step without fields + self._auth_required = False + + # Proceed to get credentials + self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle getting credentials for discovered device.""" + if user_input is None: + data_schema = vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ) + if not self._auth_required: + data_schema = vol.Schema({}) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=data_schema, + description_placeholders={"host": str(self.host)}, + ) + + if not self._auth_required: + return self._async_create_entry() + + self.passkey = user_input.get(CONF_PASSKEY) + self.username = user_input.get(CONF_USERNAME) + self.password = user_input.get(CONF_PASSWORD) + + return await self._validate_and_create(is_discovery=True) + + async def _validate_and_create( + self, is_discovery: bool = False + ) -> ConfigFlowResult: + """Validate device connection and create entry.""" try: - await self._get_bsblan_info() + await self._get_bsblan_info(is_discovery=is_discovery) except BSBLANError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "cannot_connect"}, + description_placeholders={"host": str(self.host)}, + ) return self._show_setup_form({"base": "cannot_connect"}) return self._async_create_entry() @@ -67,6 +173,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): @callback def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" return self.async_create_entry( title=format_mac(self.mac), data={ @@ -78,8 +185,10 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: - """Get device information from an BSBLAN device.""" + async def _get_bsblan_info( + self, raise_on_progress: bool = True, is_discovery: bool = False + ) -> None: + """Get device information from a BSBLAN device.""" config = BSBLANConfig( host=self.host, passkey=self.passkey, @@ -90,11 +199,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) bsblan = BSBLAN(config, session) device = await bsblan.device() - self.mac = device.MAC + retrieved_mac = device.MAC - await self.async_set_unique_id( - format_mac(self.mac), raise_on_progress=raise_on_progress - ) + # Handle unique ID assignment based on whether MAC was available from zeroconf + if not self.mac: + # MAC wasn't available from zeroconf, now we have it from API + self.mac = retrieved_mac + await self.async_set_unique_id( + format_mac(self.mac), raise_on_progress=raise_on_progress + ) + + # Always allow updating host/port for both user and discovery flows + # This ensures connectivity is maintained when devices change IP addresses self._abort_if_unique_id_configured( updates={ CONF_HOST: self.host, diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 8ea339f76c4..c5245524e28 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,11 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==2.1.0"] + "requirements": ["python-bsblan==2.1.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "bsb-lan*" + } + ] } diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 6a6784a4542..7f3f7f48afc 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData from .coordinator import BSBLanCoordinatorData from .entity import BSBLanEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class BSBLanSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 93562763999..cd4633dfb86 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -13,7 +13,25 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your BSB-Lan device." + "host": "The hostname or IP address of your BSB-Lan device.", + "port": "The port number of your BSB-Lan device.", + "passkey": "The passkey for your BSB-Lan device.", + "username": "The username for your BSB-Lan device.", + "password": "The password for your BSB-Lan device." + } + }, + "discovery_confirm": { + "title": "BSB-Lan device discovered", + "description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 47522a69c41..a3668acee8d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -568,6 +568,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "bsblan", + "name": "bsb-lan*", + }, { "domain": "eheimdigital", "name": "eheimdigital._http._tcp.local.", diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 91e4338d688..72360ece687 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,19 +1,124 @@ """Tests for the BSBLan device config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from bsblan import BSBLANConnectionError +import pytest -from homeassistant.components.bsblan import config_flow from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME 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.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +# ZeroconfServiceInfo fixtures for different discovery scenarios + + +@pytest.fixture +def zeroconf_discovery_info() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device with MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "00:80:41:19:69:90"}, + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_no_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device without MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={}, # No MAC in properties + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info with a different MAC than the device API returns.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "aa:bb:cc:dd:ee:ff"}, # Different MAC than in device.json + port=80, + hostname="BSB-LAN.local.", + ) + + +# Helper functions to reduce repetition + + +async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None): + """Initialize a user config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + +async def _init_zeroconf_flow(hass: HomeAssistant, discovery_info): + """Initialize a zeroconf config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + +async def _configure_flow(hass: HomeAssistant, flow_id: str, user_input: dict): + """Configure a flow with user input.""" + return await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + +def _assert_create_entry_result( + result, expected_title: str, expected_data: dict, expected_unique_id: str +): + """Assert that result is a successful CREATE_ENTRY.""" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == expected_data + assert "result" in result + assert result["result"].unique_id == expected_unique_id + + +def _assert_form_result( + result, expected_step_id: str, expected_errors: dict | None = None +): + """Assert that result is a FORM with correct step and optional errors.""" + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == expected_step_id + if expected_errors is None: + # Handle both None and {} as valid "no errors" states (like other integrations) + assert result.get("errors") in ({}, None) + else: + assert result.get("errors") == expected_errors + + +def _assert_abort_result(result, expected_reason: str): + """Assert that result is an ABORT with correct reason.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -21,17 +126,13 @@ async def test_full_user_flow_implementation( mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) + result = await _init_user_flow(hass) + _assert_form_result(result, "user") - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - result2 = await hass.config_entries.flow.async_configure( + result2 = await _configure_flow( + hass, result["flow_id"], - user_input={ + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -40,17 +141,18 @@ async def test_full_user_flow_implementation( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == format_mac("00:80:41:19:69:90") - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_PORT: 80, - CONF_PASSKEY: "1234", - CONF_USERNAME: "admin", - CONF_PASSWORD: "admin1234", - } - assert "result" in result2 - assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_bsblan.device.mock_calls) == 1 @@ -58,13 +160,8 @@ async def test_full_user_flow_implementation( async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM + result = await _init_user_flow(hass) + _assert_form_result(result, "user") async def test_connection_error( @@ -74,10 +171,9 @@ async def test_connection_error( """Test we show user form on BSBLan connection error.""" mock_bsblan.device.side_effect = BSBLANConnectionError - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -86,9 +182,7 @@ async def test_connection_error( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} - assert result.get("step_id") == "user" + _assert_form_result(result, "user", {"base": "cannot_connect"}) async def test_user_device_exists_abort( @@ -98,10 +192,10 @@ async def test_user_device_exists_abort( ) -> None: """Test we abort flow if BSBLAN device already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -110,5 +204,366 @@ async def test_user_device_exists_abort( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test the Zeroconf discovery flow.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_abort_if_existing_entry_for_zeroconf( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test we abort if the same host/port already exists during zeroconf discovery.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery_no_mac_requires_auth( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement and device requires auth.""" + # Make the first API call (without auth) fail, second call (with auth) succeed + mock_bsblan.device.side_effect = [ + BSBLANConnectionError, + mock_bsblan.device.return_value, + ] + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_form_result(result, "discovery_confirm") + + # Reset side_effect for the second call to succeed + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + ) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + "00:80:41:19:69:90", + ) + + # Should be called 3 times: once without auth (fails), twice with auth (in _validate_and_create) + assert len(mock_bsblan.device.mock_calls) == 3 + + +async def test_zeroconf_discovery_no_mac_no_auth_required( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement but device accessible without auth.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + + # Should now show the discovery_confirm form to the user + _assert_form_result(result, "discovery_confirm") + + # User confirms the discovery + result2 = await _configure_flow(hass, result["flow_id"], {}) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: None, + CONF_PASSWORD: None, + }, + "00:80:41:19:69:90", + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should be called once in zeroconf step, as _validate_and_create is skipped + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_zeroconf_discovery_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery shows the correct form.""" + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + +async def test_zeroconf_discovery_updates_host_port_on_existing_entry( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test that discovered devices update host/port of existing entries.""" + # Create an existing entry with different host/port + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", # Different IP + CONF_PORT: 8080, # Different port + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port from discovery + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host from discovery + assert entry.data[CONF_PORT] == 80 # Updated port from discovery + + +async def test_user_flow_can_update_existing_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that manual user configuration can update host/port of existing entries.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Try to configure the same device with different host/port via user flow + result = await _init_user_flow( + hass, + { + CONF_HOST: "10.0.2.60", # Different IP + CONF_PORT: 80, # Different port + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port (user flow behavior) + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host + assert entry.data[CONF_PORT] == 80 # Updated port + + +async def test_zeroconf_discovery_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery can be recovered from.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result3 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result3, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can recover from BSBLan connection error in user flow.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_zeroconf_discovery_no_mac_duplicate_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery aborts when no MAC and same host/port already configured.""" + # Create an existing entry with same host/port but no unique_id + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.2.60", # Same IP as discovery + CONF_PORT: 80, # Same port as discovery + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id=None, # Old entry without unique_id + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_abort_result(result, "already_configured") + + # Should not call device API since we abort early + assert len(mock_bsblan.device.mock_calls) == 0 From 66641356cc19dfa717cd480cb4f22bc2f33bdd55 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:35:57 +0200 Subject: [PATCH 0220/1118] Add Uptime Kuma integration (#146393) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/uptime_kuma/__init__.py | 27 + .../components/uptime_kuma/config_flow.py | 79 ++ homeassistant/components/uptime_kuma/const.py | 26 + .../components/uptime_kuma/coordinator.py | 107 ++ .../components/uptime_kuma/icons.json | 32 + .../components/uptime_kuma/manifest.json | 11 + .../components/uptime_kuma/quality_scale.yaml | 78 ++ .../components/uptime_kuma/sensor.py | 178 ++++ .../components/uptime_kuma/strings.json | 94 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/uptime_kuma/__init__.py | 1 + tests/components/uptime_kuma/conftest.py | 101 ++ .../uptime_kuma/snapshots/test_sensor.ambr | 968 ++++++++++++++++++ .../uptime_kuma/test_config_flow.py | 122 +++ tests/components/uptime_kuma/test_init.py | 52 + tests/components/uptime_kuma/test_sensor.py | 97 ++ 22 files changed, 1999 insertions(+) create mode 100644 homeassistant/components/uptime_kuma/__init__.py create mode 100644 homeassistant/components/uptime_kuma/config_flow.py create mode 100644 homeassistant/components/uptime_kuma/const.py create mode 100644 homeassistant/components/uptime_kuma/coordinator.py create mode 100644 homeassistant/components/uptime_kuma/icons.json create mode 100644 homeassistant/components/uptime_kuma/manifest.json create mode 100644 homeassistant/components/uptime_kuma/quality_scale.yaml create mode 100644 homeassistant/components/uptime_kuma/sensor.py create mode 100644 homeassistant/components/uptime_kuma/strings.json create mode 100644 tests/components/uptime_kuma/__init__.py create mode 100644 tests/components/uptime_kuma/conftest.py create mode 100644 tests/components/uptime_kuma/snapshots/test_sensor.ambr create mode 100644 tests/components/uptime_kuma/test_config_flow.py create mode 100644 tests/components/uptime_kuma/test_init.py create mode 100644 tests/components/uptime_kuma/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 77e853262a1..626fc10a4c2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -535,6 +535,7 @@ homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* homeassistant.components.uptime.* +homeassistant.components.uptime_kuma.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.uvc.* diff --git a/CODEOWNERS b/CODEOWNERS index 74c066a96c9..a6ab083e07d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1658,6 +1658,8 @@ build.json @home-assistant/supervisor /tests/components/upnp/ @StevenLooman /homeassistant/components/uptime/ @frenck /tests/components/uptime/ @frenck +/homeassistant/components/uptime_kuma/ @tr4nt0r +/tests/components/uptime_kuma/ @tr4nt0r /homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/usb/ @bdraco diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..0215c83f0cc --- /dev/null +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -0,0 +1,27 @@ +"""The Uptime Kuma integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Set up Uptime Kuma from a config entry.""" + + coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py new file mode 100644 index 00000000000..9866f08bef3 --- /dev/null +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for the Uptime Kuma integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_API_KEY, default=""): str, + } +) + + +class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Kuma.""" + + 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]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=url.host or "", + data={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py new file mode 100644 index 00000000000..2bd4b1f9165 --- /dev/null +++ b/homeassistant/components/uptime_kuma/const.py @@ -0,0 +1,26 @@ +"""Constants for the Uptime Kuma integration.""" + +from pythonkuma import MonitorType + +DOMAIN = "uptime_kuma" + +HAS_CERT = { + MonitorType.HTTP, + MonitorType.KEYWORD, + MonitorType.JSON_QUERY, +} +HAS_URL = HAS_CERT | {MonitorType.REAL_BROWSER} +HAS_PORT = { + MonitorType.PORT, + MonitorType.STEAM, + MonitorType.GAMEDIG, + MonitorType.MQTT, + MonitorType.RADIUS, + MonitorType.SNMP, + MonitorType.SMTP, +} +HAS_HOST = HAS_PORT | { + MonitorType.PING, + MonitorType.TAILSCALE_PING, + MonitorType.DNS, +} diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py new file mode 100644 index 00000000000..788d37cfb84 --- /dev/null +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -0,0 +1,107 @@ +"""Coordinator for the Uptime Kuma integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, + UptimeKumaMonitor, + UptimeKumaVersion, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] + + +class UptimeKumaDataUpdateCoordinator( + DataUpdateCoordinator[dict[str | int, UptimeKumaMonitor]] +): + """Update coordinator for Uptime Kuma.""" + + config_entry: UptimeKumaConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: UptimeKumaConfigEntry + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) + self.api = UptimeKuma( + session, config_entry.data[CONF_URL], config_entry.data[CONF_API_KEY] + ) + self.version: UptimeKumaVersion | None = None + + async def _async_update_data(self) -> dict[str | int, UptimeKumaMonitor]: + """Fetch the latest data from Uptime Kuma.""" + + try: + metrics = await self.api.metrics() + except UptimeKumaAuthenticationException as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_failed_exception", + ) from e + except UptimeKumaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_failed_exception", + ) from e + else: + async_migrate_entities_unique_ids(self.hass, self, metrics) + self.version = self.api.version + + return metrics + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, + coordinator: UptimeKumaDataUpdateCoordinator, + metrics: dict[str | int, UptimeKumaMonitor], +) -> None: + """Migrate unique_ids in the entity registry after updating Uptime Kuma.""" + + if ( + coordinator.version is coordinator.api.version + or int(coordinator.api.version.major) < 2 + ): + return + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, coordinator.config_entry.entry_id + ) + + for registry_entry in registry_entries: + name = registry_entry.unique_id.removeprefix( + f"{registry_entry.config_entry_id}_" + ).removesuffix(f"_{registry_entry.translation_key}") + if monitor := next( + (m for m in metrics.values() if m.monitor_name == name), None + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", + ) diff --git a/homeassistant/components/uptime_kuma/icons.json b/homeassistant/components/uptime_kuma/icons.json new file mode 100644 index 00000000000..73f5fd63661 --- /dev/null +++ b/homeassistant/components/uptime_kuma/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "sensor": { + "cert_days_remaining": { + "default": "mdi:certificate" + }, + "response_time": { + "default": "mdi:timeline-clock-outline" + }, + "status": { + "default": "mdi:lan-connect", + "state": { + "down": "mdi:lan-disconnect", + "pending": "mdi:lan-pending", + "maintenance": "mdi:account-hard-hat-outline" + } + }, + "type": { + "default": "mdi:protocol" + }, + "url": { + "default": "mdi:web" + }, + "hostname": { + "default": "mdi:ip-outline" + }, + "port": { + "default": "mdi:ip-outline" + } + } + } +} diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json new file mode 100644 index 00000000000..6f20d4ae20f --- /dev/null +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "uptime_kuma", + "name": "Uptime Kuma", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", + "iot_class": "cloud_polling", + "loggers": ["pythonkuma"], + "quality_scale": "bronze", + "requirements": ["pythonkuma==0.3.0"] +} diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml new file mode 100644 index 00000000000..145cbf58448 --- /dev/null +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: integration has no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: is not locally discoverable + discovery: + status: exempt + comment: is not locally discoverable + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: integration is a service + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: has no repairs + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py new file mode 100644 index 00000000000..c76fbcae04c --- /dev/null +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -0,0 +1,178 @@ +"""Sensor platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pythonkuma import MonitorType, UptimeKumaMonitor +from pythonkuma.models import MonitorStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +class UptimeKumaSensor(StrEnum): + """Uptime Kuma sensors.""" + + CERT_DAYS_REMAINING = "cert_days_remaining" + RESPONSE_TIME = "response_time" + STATUS = "status" + TYPE = "type" + URL = "url" + HOSTNAME = "hostname" + PORT = "port" + + +@dataclass(kw_only=True, frozen=True) +class UptimeKumaSensorEntityDescription(SensorEntityDescription): + """Uptime Kuma sensor description.""" + + value_fn: Callable[[UptimeKumaMonitor], StateType] + create_entity: Callable[[MonitorType], bool] + + +SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = ( + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.CERT_DAYS_REMAINING, + translation_key=UptimeKumaSensor.CERT_DAYS_REMAINING, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda m: m.monitor_cert_days_remaining, + create_entity=lambda t: t in HAS_CERT, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.RESPONSE_TIME, + translation_key=UptimeKumaSensor.RESPONSE_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=( + lambda m: m.monitor_response_time if m.monitor_response_time > -1 else None + ), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.STATUS, + translation_key=UptimeKumaSensor.STATUS, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorStatus], + value_fn=lambda m: m.monitor_status.name.lower(), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.TYPE, + translation_key=UptimeKumaSensor.TYPE, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorType], + value_fn=lambda m: m.monitor_type.name.lower(), + entity_category=EntityCategory.DIAGNOSTIC, + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.URL, + translation_key=UptimeKumaSensor.URL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_url, + create_entity=lambda t: t in HAS_URL, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.HOSTNAME, + translation_key=UptimeKumaSensor.HOSTNAME, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_hostname, + create_entity=lambda t: t in HAS_HOST, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.PORT, + translation_key=UptimeKumaSensor.PORT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_port, + create_entity=lambda t: t in HAS_PORT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + monitor_added: set[str | int] = set() + + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal monitor_added + + if new_monitor := set(coordinator.data.keys()) - monitor_added: + async_add_entities( + UptimeKumaSensorEntity(coordinator, monitor, description) + for description in SENSOR_DESCRIPTIONS + for monitor in new_monitor + if description.create_entity(coordinator.data[monitor].monitor_type) + ) + monitor_added |= new_monitor + + coordinator.async_add_listener(add_entities) + add_entities() + + +class UptimeKumaSensorEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], SensorEntity +): + """An Uptime Kuma sensor entity.""" + + entity_description: UptimeKumaSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + monitor: str | int, + entity_description: UptimeKumaSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + self.monitor = monitor + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data[monitor].monitor_name, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data[self.monitor]) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.monitor in self.coordinator.data diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json new file mode 100644 index 00000000000..8cd361cccea --- /dev/null +++ b/homeassistant/components/uptime_kuma/strings.json @@ -0,0 +1,94 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up **Uptime Kuma** monitoring service", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "Enter the full URL of your Uptime Kuma instance. Be sure to include the protocol (`http` or `https`), the hostname or IP address, the port number (if it is a non-default port), and any path prefix if applicable. Example: `https://uptime.example.com`", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", + "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" + } + } + }, + "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": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "cert_days_remaining": { + "name": "Certificate expiry" + }, + "response_time": { + "name": "Response time" + }, + "status": { + "name": "Status", + "state": { + "up": "Up", + "down": "Down", + "pending": "Pending", + "maintenance": "Maintenance" + } + }, + "type": { + "name": "Monitor type", + "state": { + "http": "HTTP(s)", + "port": "TCP port", + "ping": "Ping", + "keyword": "HTTP(s) - Keyword", + "dns": "DNS", + "push": "Push", + "steam": "Steam Game Server", + "mqtt": "MQTT", + "sqlserver": "Microsoft SQL Server", + "json_query": "HTTP(s) - JSON query", + "group": "Group", + "docker": "Docker", + "grpc_keyword": "gRPC(s) - Keyword", + "real_browser": "HTTP(s) - Browser engine", + "gamedig": "GameDig", + "kafka_producer": "Kafka Producer", + "postgres": "PostgreSQL", + "mysql": "MySQL/MariaDB", + "mongodb": "MongoDB", + "radius": "Radius", + "redis": "Redis", + "tailscale_ping": "Tailscale Ping", + "snmp": "SNMP", + "smtp": "SMTP", + "rabbit_mq": "RabbitMQ", + "manual": "Manual" + } + }, + "url": { + "name": "Monitored URL" + }, + "hostname": { + "name": "Monitored hostname" + }, + "port": { + "name": "Monitored port" + } + } + }, + "exceptions": { + "auth_failed_exception": { + "message": "Authentication with Uptime Kuma failed. Please check that your API key is correct and still valid" + }, + "request_failed_exception": { + "message": "Connection to Uptime Kuma failed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 97e7929d317..92319af9617 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -680,6 +680,7 @@ FLOWS = { "upcloud", "upnp", "uptime", + "uptime_kuma", "uptimerobot", "v2c", "vallox", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec790549519..277400bec02 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7080,6 +7080,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "uptime_kuma": { + "name": "Uptime Kuma", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "uptimerobot": { "name": "UptimeRobot", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 48432118fa8..25039f7f386 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5109,6 +5109,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptime_kuma.*] +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.uptimerobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 52b7555b6fe..53bc939f588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2525,6 +2525,9 @@ python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8be5f73588..a18908ffe97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2089,6 +2089,9 @@ python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/tests/components/uptime_kuma/__init__.py b/tests/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..ba8ab82dc46 --- /dev/null +++ b/tests/components/uptime_kuma/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Kuma integration.""" diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py new file mode 100644 index 00000000000..4b7710a48b4 --- /dev/null +++ b/tests/components/uptime_kuma/conftest.py @@ -0,0 +1,101 @@ +"""Common fixtures for the Uptime Kuma tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion +from pythonkuma.models import MonitorStatus + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.uptime_kuma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Uptime Kuma configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="uptime.example.org", + data={ + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + ) + + +@pytest.fixture +def mock_pythonkuma() -> Generator[AsyncMock]: + """Mock pythonkuma client.""" + + monitor_1 = UptimeKumaMonitor( + monitor_id=1, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 1", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.HTTP, + monitor_url="https://example.org", + ) + monitor_2 = UptimeKumaMonitor( + monitor_id=2, + monitor_cert_days_remaining=0, + monitor_cert_is_valid=0, + monitor_hostname=None, + monitor_name="Monitor 2", + monitor_port=None, + monitor_response_time=28, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.PORT, + monitor_url=None, + ) + monitor_3 = UptimeKumaMonitor( + monitor_id=3, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 3", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.DOWN, + monitor_type=MonitorType.JSON_QUERY, + monitor_url="https://down.example.org", + ) + + with ( + patch( + "homeassistant.components.uptime_kuma.config_flow.UptimeKuma", autospec=True + ) as mock_client, + patch( + "homeassistant.components.uptime_kuma.coordinator.UptimeKuma", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.metrics.return_value = { + 1: monitor_1, + 2: monitor_2, + 3: monitor_3, + } + client.version = UptimeKumaVersion( + version="2.0.0", major="2", minor="0", patch="0" + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..49a7d141c47 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -0,0 +1,968 @@ +# serializer version: 1 +# name: test_setup[sensor.monitor_1_certificate_expiry-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.monitor_1_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'http', + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-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.monitor_1_monitored_url', + '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': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 1 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://example.org', + }) +# --- +# name: test_setup[sensor.monitor_1_response_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': None, + 'entity_id': 'sensor.monitor_1_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'port', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-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.monitor_2_monitored_hostname', + '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': 'Monitored hostname', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_hostname', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored hostname', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-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.monitor_2_monitored_port', + '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': 'Monitored port', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_port', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored port', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_response_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': None, + 'entity_id': 'sensor.monitor_2_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 2 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_2_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_setup[sensor.monitor_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-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.monitor_3_certificate_expiry', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'json_query', + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-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.monitor_3_monitored_url', + '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': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 3 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://down.example.org', + }) +# --- +# name: test_setup[sensor.monitor_3_response_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': None, + 'entity_id': 'sensor.monitor_3_response_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_3_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'down', + }) +# --- diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py new file mode 100644 index 00000000000..b70cb9d353c --- /dev/null +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Uptime Kuma config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaConnectionException + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py new file mode 100644 index 00000000000..57390da60d5 --- /dev/null +++ b/tests/components/uptime_kuma/test_init.py @@ -0,0 +1,52 @@ +"""Tests for the Uptime Kuma integration.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (UptimeKumaAuthenticationException, ConfigEntryState.SETUP_ERROR), + (UptimeKumaException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_pythonkuma.metrics.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state diff --git a/tests/components/uptime_kuma/test_sensor.py b/tests/components/uptime_kuma/test_sensor.py new file mode 100644 index 00000000000..25bd7650528 --- /dev/null +++ b/tests/components/uptime_kuma/test_sensor.py @@ -0,0 +1,97 @@ +"""Test for Uptime Kuma sensor platform.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_migrate_unique_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Snapshot test states of sensor platform.""" + mock_pythonkuma.metrics.return_value = { + "Monitor": UptimeKumaMonitor( + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="1.23.16", major="1", minor="23", patch="16" + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_Monitor_status" + + mock_pythonkuma.metrics.return_value = { + 1: UptimeKumaMonitor( + monitor_id=1, + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3" + ) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_1_status" From f65fa3842932ece090e62b508945f9e8d4eaf136 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 14 Jul 2025 21:49:52 +0200 Subject: [PATCH 0221/1118] Add reconfigure flow for KNX (#145067) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/config_flow.py | 144 ++++--- .../components/knx/quality_scale.yaml | 2 +- homeassistant/components/knx/strings.json | 162 +------- tests/components/knx/conftest.py | 3 + tests/components/knx/test_config_flow.py | 381 +++++++++--------- 5 files changed, 290 insertions(+), 402 deletions(-) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 14a9016bcb9..796c4c60201 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any, Final, Literal @@ -20,8 +19,8 @@ from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, - ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -103,12 +102,14 @@ _PORT_SELECTOR = vol.All( ) -class KNXCommonFlow(ABC, ConfigEntryBaseFlow): - """Base class for KNX flows.""" +class KNXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" - def __init__(self, initial_data: KNXConfigEntryData) -> None: - """Initialize KNXCommonFlow.""" - self.initial_data = initial_data + VERSION = 1 + + def __init__(self) -> None: + """Initialize KNX config flow.""" + self.initial_data = DEFAULT_ENTRY_DATA self.new_entry_data = KNXConfigEntryData() self.new_title: str | None = None @@ -121,19 +122,21 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self._gatewayscanner: GatewayScanner | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: + """Get the options flow for this handler.""" + return KNXOptionsFlow(config_entry) + @property def _xknx(self) -> XKNX: """Return XKNX instance.""" - if isinstance(self, OptionsFlow) and ( + if (self.source == SOURCE_RECONFIGURE) and ( knx_module := self.hass.data.get(KNX_MODULE_KEY) ): return knx_module.xknx return XKNX() - @abstractmethod - def finish_flow(self) -> ConfigFlowResult: - """Finish the flow.""" - @property def connection_type(self) -> str: """Return the configured connection type.""" @@ -150,6 +153,61 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), ) + @callback + def finish_flow(self) -> ConfigFlowResult: + """Create or update the ConfigEntry.""" + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + _tunnel_endpoint_str = self.initial_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, "Tunneling" + ) + if self.new_title and not entry.title.startswith( + # Overwrite standard titles, but not user defined ones + ( + f"KNX {self.initial_data[CONF_KNX_CONNECTION_TYPE]}", + CONF_KNX_AUTOMATIC.capitalize(), + "Tunneling @ ", + f"{_tunnel_endpoint_str} @", + "Tunneling UDP @ ", + "Tunneling TCP @ ", + "Secure Tunneling", + "Routing as ", + "Secure Routing as ", + ) + ): + self.new_title = None + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.new_entry_data, + title=self.new_title or UNDEFINED, + ) + + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" + return self.async_create_entry( + title=title, + data=DEFAULT_ENTRY_DATA | self.new_entry_data, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_connection_type() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + entry = self._get_reconfigure_entry() + self.initial_data = dict(entry.data) # type: ignore[assignment] + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "connection_type", + "secure_knxkeys", + ], + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -441,7 +499,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) ip_address: str | None if ( # initial attempt on ConfigFlow or coming from automatic / routing - (isinstance(self, ConfigFlow) or not _reconfiguring_existing_tunnel) + not _reconfiguring_existing_tunnel and not user_input and self._selected_tunnel is not None ): # default to first found tunnel @@ -841,52 +899,20 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) -class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): - """Handle a KNX config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize KNX options flow.""" - super().__init__(initial_data=DEFAULT_ENTRY_DATA) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: - """Get the options flow for this handler.""" - return KNXOptionsFlow(config_entry) - - @callback - def finish_flow(self) -> ConfigFlowResult: - """Create the ConfigEntry.""" - title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" - return self.async_create_entry( - title=title, - data=DEFAULT_ENTRY_DATA | self.new_entry_data, - ) - - async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - return await self.async_step_connection_type() - - -class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): +class KNXOptionsFlow(OptionsFlow): """Handle KNX options.""" - general_settings: dict - def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] + self.initial_data = dict(config_entry.data) @callback - def finish_flow(self) -> ConfigFlowResult: + def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult: """Update the ConfigEntry and finish the flow.""" - new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + new_data = self.initial_data | new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -894,26 +920,20 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX options.""" - return self.async_show_menu( - step_id="init", - menu_options=[ - "connection_type", - "communication_settings", - "secure_knxkeys", - ], - ) + return await self.async_step_communication_settings() async def async_step_communication_settings( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX communication settings.""" if user_input is not None: - self.new_entry_data = KNXConfigEntryData( - state_updater=user_input[CONF_KNX_STATE_UPDATER], - rate_limit=user_input[CONF_KNX_RATE_LIMIT], - telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + return self.finish_flow( + KNXConfigEntryData( + state_updater=user_input[CONF_KNX_STATE_UPDATER], + rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + ) ) - return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index b4b36213c43..9e24cc1ce5b 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -104,7 +104,7 @@ rules: Since all entities are configured manually, names are user-defined. exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dc4d7de42ff..921fc2c5288 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reconfigure": { + "title": "KNX connection settings", + "menu_options": { + "connection_type": "Reconfigure KNX connection", + "secure_knxkeys": "Import KNX keyring file" + } + }, "connection_type": { "title": "KNX connection", "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", @@ -65,7 +72,7 @@ }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", + "description": "The keyring is used to encrypt and decrypt KNX IP Secure communication. You can import a new keyring file or re-import to update existing keys if your configuration has changed.", "data": { "knxkeys_file": "Keyring file", "knxkeys_password": "Keyring password" @@ -129,6 +136,9 @@ } } }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.", @@ -159,16 +169,8 @@ }, "options": { "step": { - "init": { - "title": "KNX Settings", - "menu_options": { - "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings", - "secure_knxkeys": "Import a `.knxkeys` file" - } - }, "communication_settings": { - "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", + "title": "Communication settings", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -179,147 +181,7 @@ "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } - }, - "connection_type": { - "title": "[%key:component::knx::config::step::connection_type::title%]", - "description": "[%key:component::knx::config::step::connection_type::description%]", - "data": { - "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" - }, - "data_description": { - "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" - } - }, - "tunnel": { - "title": "[%key:component::knx::config::step::tunnel::title%]", - "data": { - "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" - }, - "data_description": { - "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" - } - }, - "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "manual_tunnel": { - "title": "[%key:component::knx::config::step::manual_tunnel::title%]", - "description": "[%key:component::knx::config::step::manual_tunnel::description%]", - "data": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data::tunneling_type%]", - "port": "[%key:common::config_flow::data::port%]", - "host": "[%key:common::config_flow::data::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", - "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", - "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } - }, - "secure_key_source_menu_tunnel": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" - } - }, - "secure_key_source_menu_routing": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" - } - }, - "secure_knxkeys": { - "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", - "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", - "data": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" - }, - "data_description": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" - } - }, - "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "secure_tunnel_manual": { - "title": "[%key:component::knx::config::step::secure_tunnel_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data::device_authentication%]" - }, - "data_description": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::device_authentication%]" - } - }, - "secure_routing_manual": { - "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" - }, - "data_description": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data_description::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data_description::sync_latency_tolerance%]" - } - }, - "routing": { - "title": "[%key:component::knx::config::step::routing::title%]", - "description": "[%key:component::knx::config::step::routing::description%]", - "data": { - "individual_address": "[%key:component::knx::config::step::routing::data::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_backbone_key": "[%key:component::knx::config::error::invalid_backbone_key%]", - "invalid_individual_address": "[%key:component::knx::config::error::invalid_individual_address%]", - "invalid_ip_address": "[%key:component::knx::config::error::invalid_ip_address%]", - "keyfile_no_backbone_key": "[%key:component::knx::config::error::keyfile_no_backbone_key%]", - "keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]", - "keyfile_no_tunnel_for_host": "[%key:component::knx::config::error::keyfile_no_tunnel_for_host%]", - "keyfile_not_found": "[%key:component::knx::config::error::keyfile_not_found%]", - "no_router_discovered": "[%key:component::knx::config::error::no_router_discovered%]", - "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", - "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } }, "entity": { diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 26683ced66e..32f7745a6e0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -309,6 +309,9 @@ def mock_config_entry() -> MockConfigEntry: title="KNX", domain=DOMAIN, data={ + # homeassistant.components.knx.config_flow.DEFAULT_ENTRY_DATA has additional keys + # there are installations out there without these keys so we test with legacy data + # to ensure backwards compatibility (local_ip, telegram_log_size) CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6ebe8192f69..6457d099eb2 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -48,7 +48,7 @@ from homeassistant.components.knx.const import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, get_fixture_path @@ -174,27 +174,27 @@ async def test_routing_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -227,19 +227,19 @@ async def test_routing_setup_advanced( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} # invalid user input result_invalid_input = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group CONF_KNX_MCAST_PORT: 3675, @@ -257,8 +257,8 @@ async def test_routing_setup_advanced( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, @@ -266,9 +266,9 @@ async def test_routing_setup_advanced( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -297,18 +297,18 @@ async def test_routing_secure_manual_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -316,19 +316,19 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_routing_manual"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_routing_manual" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_routing_manual" + assert not result["errors"] result_invalid_key1 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "xxaacc44bbaacc44bbaacc44bbaaccyy", # invalid hex string CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -339,7 +339,7 @@ async def test_routing_secure_manual_setup( assert result_invalid_key1["errors"] == {"backbone_key": "invalid_backbone_key"} result_invalid_key2 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44", # invalid length CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -386,18 +386,18 @@ async def test_routing_secure_keyfile( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -405,20 +405,20 @@ async def test_routing_secure_keyfile( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): routing_secure_knxkeys = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", @@ -532,15 +532,15 @@ async def test_tunneling_setup_manual( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} with patch( "homeassistant.components.knx.config_flow.request_description", @@ -552,13 +552,13 @@ async def test_tunneling_setup_manual( ), ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == title - assert result3["data"] == config_entry_data + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["data"] == config_entry_data knx_setup.assert_called_once() @@ -724,19 +724,19 @@ async def test_tunneling_setup_for_local_ip( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} # invalid host ip address result_invalid_host = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: DEFAULT_MCAST_GRP, # multicast addresses are invalid @@ -752,7 +752,7 @@ async def test_tunneling_setup_for_local_ip( } # invalid local ip address result_invalid_local = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -768,8 +768,8 @@ async def test_tunneling_setup_for_local_ip( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -777,9 +777,9 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling UDP @ 192.168.0.2" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Tunneling UDP @ 192.168.0.2" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -1008,15 +1008,15 @@ async def test_form_with_automatic_connection_handling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", @@ -1032,7 +1032,9 @@ async def test_form_with_automatic_connection_handling( knx_setup.assert_called_once() -async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: +async def _get_menu_step_secure_tunnel( + hass: HomeAssistant, +) -> config_entries.ConfigFlowResult: """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", @@ -1050,23 +1052,23 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" - return result3 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" + return result @patch( @@ -1099,24 +1101,24 @@ async def test_get_secure_menu_step_manual_tunnelling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] manual_tunnel_flow = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_GATEWAY: OPTION_MANUAL_TUNNEL, }, ) - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( manual_tunnel_flow["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1124,8 +1126,8 @@ async def test_get_secure_menu_step_manual_tunnelling( CONF_PORT: 3675, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1269,52 +1271,51 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert secure_knxkeys["errors"] == {"base": "keyfile_no_tunnel_for_host"} -async def test_options_flow_connection_type( +async def test_reconfigure_flow_connection_type( hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry ) -> None: - """Test options flow changing interface.""" - # run one option flow test with a set up integration (knx fixture) + """Test reconfigure flow changing interface.""" + # run one flow test with a set up integration (knx fixture) # instead of mocking async_setup_entry (knx_setup fixture) to test # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) await knx.setup_integration() - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + menu_step = await knx.mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_KNX_GATEWAY: str(gateway), }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3["data"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_HOST: "192.168.0.1", CONF_PORT: 3675, - CONF_KNX_LOCAL_IP: None, CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_RATE_LIMIT: 0, @@ -1324,14 +1325,13 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } -async def test_options_flow_secure_manual_to_keyfile( +async def test_reconfigure_flow_secure_manual_to_keyfile( hass: HomeAssistant, knx_setup ) -> None: - """Test options flow changing secure credential source.""" + """Test reconfigure flow changing secure credential source.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1359,46 +1359,47 @@ async def test_options_flow_secure_manual_to_keyfile( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): - secure_knxkeys = await hass.config_entries.options.async_configure( - result4["flow_id"], + secure_knxkeys = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", @@ -1407,12 +1408,13 @@ async def test_options_flow_secure_manual_to_keyfile( assert result["type"] is FlowResultType.FORM assert secure_knxkeys["step_id"] == "knxkeys_tunnel_select" assert not result["errors"] - secure_knxkeys = await hass.config_entries.options.async_configure( + secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys["flow_id"], {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) - assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY + assert secure_knxkeys["type"] is FlowResultType.ABORT + assert secure_knxkeys["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1433,8 +1435,8 @@ async def test_options_flow_secure_manual_to_keyfile( knx_setup.assert_called_once() -async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: - """Test options flow changing routing settings.""" +async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow changing routing settings.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1446,36 +1448,38 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: gateway = _gateway_descriptor("192.168.0.1", 3676) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {} - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, @@ -1491,43 +1495,8 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_communication_settings( - hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry -) -> None: - """Test options flow changing communication settings.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - result = await hass.config_entries.options.async_configure( - menu_step["flow_id"], - {"next_step_id": "communication_settings"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "communication_settings" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") - assert mock_config_entry.data == { - **DEFAULT_ENTRY_DATA, - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - } - knx_setup.assert_called_once() - - -async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: - """Test options flow updating keyfile when tunnel endpoint is already configured.""" +async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow updating keyfile when tunnel endpoint is already configured.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1549,9 +1518,10 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1559,15 +1529,15 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1578,8 +1548,8 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: - """Test options flow uploading a keyfile for the first time.""" +async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow uploading a keyfile for the first time.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, @@ -1596,9 +1566,10 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1606,7 +1577,7 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, @@ -1614,17 +1585,17 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "knxkeys_tunnel_select" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "knxkeys_tunnel_select" - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1637,3 +1608,35 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, } knx_setup.assert_called_once() + + +async def test_options_communication_settings( + hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow changing communication settings.""" + initial_data = dict(mock_config_entry.data) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "communication_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert not result.get("data") + assert initial_data != dict(mock_config_entry.data) + assert mock_config_entry.data == { + **initial_data, + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + } + knx_setup.assert_called_once() From c476500c494882bf2a63fd72c4a79b1a467f43b9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 14 Jul 2025 22:40:46 +0200 Subject: [PATCH 0222/1118] Fix Shelly `n_current` sensor removal condition (#148740) --- homeassistant/components/shelly/sensor.py | 4 +- tests/components/shelly/fixtures/pro_3em.json | 2 +- .../shelly/snapshots/test_devices.ambr | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 3a6f5f221c5..cefcbb86a98 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -868,8 +868,8 @@ RPC_SENSORS: Final = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: (status and status["n_current"]) is not None, - removal_condition=lambda _config, status, _key: "n_current" not in status, + removal_condition=lambda _config, status, key: status[key].get("n_current") + is None, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json index 93351e9bc65..4895766cc49 100644 --- a/tests/components/shelly/fixtures/pro_3em.json +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -151,7 +151,7 @@ "c_pf": 0.72, "c_voltage": 230.2, "id": 0, - "n_current": null, + "n_current": 3.124, "total_act_power": 2413.825, "total_aprt_power": 2525.779, "total_current": 11.116, diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 0b8ec71771b..9dcda321057 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -4303,6 +4303,62 @@ 'state': '230.2', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_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.test_name_phase_n_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase N current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-n_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase N current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_n_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.124', + }) +# --- # name: test_shelly_pro_3em[sensor.test_name_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a81e83cb2893d24cde1ae1a6d2789a7f4c78eaf8 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Tue, 15 Jul 2025 07:38:01 +1000 Subject: [PATCH 0223/1118] Manually register powerview hub (#146709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../hunterdouglas_powerview/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 3e9ff8727ce..89624a0efbc 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,9 +11,9 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, HUB_EXCEPTIONS +from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewConfigEntry, PowerviewEntryData from .shade_data import PowerviewShadeData @@ -64,6 +64,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerviewConfigEntry) -> ) return False + # manual registration of the hub + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, hub.mac_address)}, + identifiers={(DOMAIN, hub.serial_number)}, + manufacturer=MANUFACTURER, + name=hub.name, + model=hub.model, + sw_version=hub.firmware, + hw_version=hub.main_processor_version.name, + ) + try: rooms = Rooms(pv_request) room_data: PowerviewData = await rooms.get_rooms() From 816977dd75a6145420877a64707593582f8aada1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:26:34 -0400 Subject: [PATCH 0224/1118] Refactor async_setup_platform for template platforms (#147379) --- .../template/alarm_control_panel.py | 91 +---- .../components/template/binary_sensor.py | 107 +----- homeassistant/components/template/button.py | 50 +-- homeassistant/components/template/config.py | 14 +- homeassistant/components/template/cover.py | 84 +---- homeassistant/components/template/fan.py | 84 +---- homeassistant/components/template/helpers.py | 174 ++++++++- homeassistant/components/template/image.py | 47 +-- homeassistant/components/template/light.py | 77 +--- homeassistant/components/template/lock.py | 62 +--- homeassistant/components/template/number.py | 50 +-- homeassistant/components/template/select.py | 47 +-- homeassistant/components/template/sensor.py | 95 +---- homeassistant/components/template/switch.py | 92 +---- .../components/template/template_entity.py | 38 -- homeassistant/components/template/vacuum.py | 86 +---- homeassistant/components/template/weather.py | 76 +--- .../components/template/test_binary_sensor.py | 2 +- tests/components/template/test_helpers.py | 344 ++++++++++++++++++ tests/components/template/test_light.py | 123 ------- tests/components/template/test_switch.py | 33 -- 21 files changed, 711 insertions(+), 1065 deletions(-) create mode 100644 tests/components/template/test_helpers.py diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index bac3f03afb8..a308d55e443 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -45,12 +45,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -88,7 +84,7 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -161,54 +157,6 @@ ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy alarm control panel configuration definitions to modern ones.""" - alarm_control_panels = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - alarm_control_panels.append(entity_conf) - - return alarm_control_panels - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template alarm control panels.""" - alarm_control_panels = [] - - 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}" - - alarm_control_panels.append( - AlarmControlPanelTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(alarm_control_panels) - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -231,7 +179,7 @@ async def async_setup_entry( validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) async_add_entities( [ - AlarmControlPanelTemplate( + StateAlarmControlPanelEntity( hass, validated_config, config_entry.entry_id, @@ -247,27 +195,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + ALARM_CONTROL_PANEL_DOMAIN, + config, + StateAlarmControlPanelEntity, + TriggerAlarmControlPanelEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_ALARM_CONTROL_PANELS, ) @@ -414,7 +351,7 @@ class AbstractTemplateAlarmControlPanel( ) -class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel): +class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlPanel): """Representation of a templated Alarm Control Panel.""" _attr_should_poll = False diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index b3bbf37712f..6d41a5804b6 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -24,9 +24,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, CONF_SENSORS, @@ -53,18 +51,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTES, - CONF_AVAILABILITY, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_PICTURE, -) -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -73,12 +62,7 @@ CONF_AUTO_OFF = "auto_off" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -121,27 +105,6 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy binary sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( @@ -151,33 +114,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( ) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template binary sensors.""" - sensors = [] - - 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}" - - sensors.append( - BinarySensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -185,27 +121,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template binary sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerBinarySensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + BINARY_SENSOR_DOMAIN, + config, + StateBinarySensorEntity, + TriggerBinarySensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -219,20 +144,20 @@ async def async_setup_entry( _options.pop("template_type") validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) async_add_entities( - [BinarySensorTemplate(hass, validated_config, config_entry.entry_id)] + [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] ) @callback def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> BinarySensorTemplate: +) -> StateBinarySensorEntity: """Create a preview sensor.""" validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return BinarySensorTemplate(hass, validated_config, None) + return StateBinarySensorEntity(hass, validated_config, None) -class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): +class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 07aa41b3811..c52e2dae5a0 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,20 +3,17 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, +from homeassistant.components.button import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BUTTON_DOMAIN, + ButtonEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( @@ -26,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) @@ -50,19 +48,6 @@ CONFIG_BUTTON_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateButtonEntity]: - """Create the Template button.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateButtonEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -70,15 +55,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template button.""" - if not discovery_info or "coordinator" in discovery_info: - raise PlatformNotReady( - "The template button platform doesn't support trigger entities" - ) - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + BUTTON_DOMAIN, + config, + StateButtonEntity, + None, + async_add_entities, + discovery_info, ) @@ -92,11 +76,11 @@ async def async_setup_entry( _options.pop("template_type") validated_config = CONFIG_BUTTON_SCHEMA(_options) async_add_entities( - [TemplateButtonEntity(hass, validated_config, config_entry.entry_id)] + [StateButtonEntity(hass, validated_config, config_entry.entry_id)] ) -class TemplateButtonEntity(TemplateEntity, ButtonEntity): +class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 86769a0d22a..1b3e9986d36 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -65,7 +65,7 @@ from . import ( weather as weather_platform, ) from .const import DOMAIN, PLATFORMS, TemplateConfig -from .helpers import async_get_blueprints +from .helpers import async_get_blueprints, rewrite_legacy_to_modern_configs PACKAGE_MERGE_HINT = "list" @@ -249,16 +249,16 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf legacy_warn_printed = False - for old_key, new_key, transform in ( + for old_key, new_key, legacy_fields in ( ( CONF_SENSORS, DOMAIN_SENSOR, - sensor_platform.rewrite_legacy_to_modern_conf, + sensor_platform.LEGACY_FIELDS, ), ( CONF_BINARY_SENSORS, DOMAIN_BINARY_SENSOR, - binary_sensor_platform.rewrite_legacy_to_modern_conf, + binary_sensor_platform.LEGACY_FIELDS, ), ): if old_key not in template_config: @@ -276,7 +276,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf definitions = ( list(template_config[new_key]) if new_key in template_config else [] ) - definitions.extend(transform(hass, template_config[old_key])) + definitions.extend( + rewrite_legacy_to_modern_configs( + hass, template_config[old_key], legacy_fields + ) + ) template_config = TemplateConfig({**template_config, new_key: definitions}) config_sections.append(template_config) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 68645c718b2..9d6391d80c9 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -39,12 +39,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -85,7 +84,7 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, CONF_POSITION_TEMPLATE: CONF_POSITION, CONF_TILT_TEMPLATE: CONF_TILT, @@ -140,54 +139,6 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - covers = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - covers.append(entity_conf) - - return covers - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - covers = [] - - 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}" - - covers.append( - CoverTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(covers) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -195,27 +146,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerCoverEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + COVER_DOMAIN, + config, + StateCoverEntity, + TriggerCoverEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_COVERS, ) @@ -445,7 +385,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): self.async_write_ha_state() -class CoverTemplate(TemplateEntity, AbstractTemplateCover): +class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" _attr_should_poll = False diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f7b0b57cf27..95086375f4b 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -41,12 +41,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -73,7 +72,7 @@ CONF_OSCILLATING = "oscillating" CONF_PERCENTAGE = "percentage" CONF_PRESET_MODE = "preset_mode" -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, @@ -132,54 +131,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy fan configuration definitions to modern ones.""" - fans = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - fans.append(entity_conf) - - return fans - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - 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}" - - fans.append( - TemplateFan( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -187,27 +138,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerFanEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + FAN_DOMAIN, + config, + StateFanEntity, + TriggerFanEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_FANS, ) @@ -484,7 +424,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ) -class TemplateFan(TemplateEntity, AbstractTemplateFan): +class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 2cd587de5a1..514255f417a 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -1,19 +1,60 @@ """Helpers for template integration.""" +from collections.abc import Callable +import itertools import logging +from typing import Any from homeassistant.components import blueprint -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import ( + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_UNIQUE_ID, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_platforms, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ( + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_OBJECT_ID, + CONF_PICTURE, + DOMAIN, +) from .entity import AbstractTemplateEntity +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity DATA_BLUEPRINTS = "template_blueprints" -LOGGER = logging.getLogger(__name__) +LEGACY_FIELDS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, + CONF_FRIENDLY_NAME: CONF_NAME, +} + +_LOGGER = logging.getLogger(__name__) + +type CreateTemplateEntitiesCallback = Callable[ + [type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None], + None, +] @callback @@ -59,8 +100,131 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: return blueprint.DomainBlueprints( hass, DOMAIN, - LOGGER, + _LOGGER, _blueprint_in_use, _reload_blueprint_templates, TEMPLATE_BLUEPRINT_SCHEMA, ) + + +def rewrite_legacy_to_modern_config( + hass: HomeAssistant, + entity_cfg: dict[str, Any], + extra_legacy_fields: dict[str, str], +) -> dict[str, Any]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + + for from_key, to_key in itertools.chain( + LEGACY_FIELDS.items(), extra_legacy_fields.items() + ): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val, hass) + entity_cfg[to_key] = val + + if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): + entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) + + return entity_cfg + + +def rewrite_legacy_to_modern_configs( + hass: HomeAssistant, + entity_cfg: dict[str, dict], + extra_legacy_fields: dict[str, str], +) -> list[dict]: + """Rewrite legacy configuration definitions to modern ones.""" + entities = [] + for object_id, entity_conf in entity_cfg.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_legacy_to_modern_config( + hass, entity_conf, extra_legacy_fields + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + entities.append(entity_conf) + + return entities + + +@callback +def async_create_template_tracking_entities( + entity_cls: type[Entity], + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template tracking entities.""" + entities: list[Entity] = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg] + async_add_entities(entities) + + +async def async_setup_template_platform( + hass: HomeAssistant, + domain: str, + config: ConfigType, + state_entity_cls: type[TemplateEntity], + trigger_entity_cls: type[TriggerEntity] | None, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, + legacy_fields: dict[str, str] | None = None, + legacy_key: str | None = None, +) -> None: + """Set up the Template platform.""" + if discovery_info is None: + # Legacy Configuration + if legacy_fields is not None: + if legacy_key: + configs = rewrite_legacy_to_modern_configs( + hass, config[legacy_key], legacy_fields + ) + else: + configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)] + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + configs, + None, + ) + else: + _LOGGER.warning( + "Template %s entities can only be configured under template:", domain + ) + return + + # Trigger Configuration + if "coordinator" in discovery_info: + if trigger_entity_cls: + entities = [ + trigger_entity_cls(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ] + async_add_entities(entities) + else: + raise PlatformNotReady( + f"The template {domain} platform doesn't support trigger entities" + ) + return + + # Modern Configuration + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index d286a2f6b4d..5f7f06faf4f 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -9,13 +9,7 @@ import voluptuous as vol from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector @@ -29,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE +from .helpers import async_setup_template_platform from .template_entity import ( TemplateEntity, make_template_entity_common_modern_attributes_schema, @@ -59,19 +54,6 @@ IMAGE_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[StateImageEntity]: - """Create the template image.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(StateImageEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -79,23 +61,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template image.""" - if discovery_info is None: - _LOGGER.warning( - "Template image entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerImageEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + IMAGE_DOMAIN, + config, + StateImageEntity, + TriggerImageEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 10870462bc9..438c295ecd5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -51,12 +51,11 @@ from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -103,7 +102,7 @@ CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_COLOR_ACTION: CONF_HS_ACTION, CONF_COLOR_TEMPLATE: CONF_HS, CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, @@ -193,47 +192,6 @@ PLATFORM_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - lights = [] - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - lights.append(entity_conf) - - return lights - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the Template Lights.""" - lights = [] - - 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}" - - lights.append(LightTemplate(hass, entity_conf, unique_id)) - - async_add_entities(lights) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -241,27 +199,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLightEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LIGHT_DOMAIN, + config, + StateLightEntity, + TriggerLightEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_LIGHTS, ) @@ -934,7 +881,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.TRANSITION -class LightTemplate(TemplateEntity, AbstractTemplateLight): +class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 4e3f3ed8ccc..20bc098d130 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,12 +31,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PICTURE, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -49,7 +48,7 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -83,33 +82,6 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.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 template fans.""" - fans = [] - - 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}" - - fans.append( - TemplateLock( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -117,27 +89,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLockEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LOCK_DOMAIN, + config, + StateLockEntity, + TriggerLockEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, ) @@ -311,7 +271,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): ) -class TemplateLock(TemplateEntity, AbstractTemplateLock): +class StateLockEntity(TemplateEntity, AbstractTemplateLock): """Representation of a template lock.""" _attr_should_poll = False diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 4d9eaff0b2d..fa1e2790a9d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -21,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback @@ -35,6 +34,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -70,19 +70,6 @@ NUMBER_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateNumber]: - """Create the Template number.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateNumber(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -90,23 +77,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template number.""" - if discovery_info is None: - _LOGGER.warning( - "Template number entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerNumberEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + NUMBER_DOMAIN, + config, + StateNumberEntity, + TriggerNumberEntity, + async_add_entities, + discovery_info, ) @@ -119,19 +97,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> TemplateNumber: +) -> StateNumberEntity: """Create a preview number.""" validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateNumber(hass, validated_config, None) + return StateNumberEntity(hass, validated_config, None) -class TemplateNumber(TemplateEntity, NumberEntity): +class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 256955e70a8..55b5c7375f8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -14,13 +14,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id @@ -33,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -65,19 +60,6 @@ SELECT_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateSelect]: - """Create the Template select.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateSelect(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -85,23 +67,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template select.""" - if discovery_info is None: - _LOGGER.warning( - "Template select entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSelectEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + SELECT_DOMAIN, + config, + TemplateSelect, + TriggerSelectEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index c25a2a0e3cb..11fe279fdfb 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -56,16 +56,12 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity LEGACY_FIELDS = { CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -142,27 +138,6 @@ def extra_validation_checks(val): return val -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { @@ -177,33 +152,6 @@ PLATFORM_SCHEMA = vol.All( _LOGGER = logging.getLogger(__name__) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template sensors.""" - sensors = [] - - 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}" - - sensors.append( - SensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -211,27 +159,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SENSOR_DOMAIN, + config, + StateSensorEntity, + TriggerSensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -244,19 +181,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SensorTemplate: +) -> StateSensorEntity: """Create a preview sensor.""" validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SensorTemplate(hass, validated_config, None) + return StateSensorEntity(hass, validated_config, None) -class SensorTemplate(TemplateEntity, SensorEntity): +class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 677686ea8d8..e2ccb5a8a82 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -41,18 +41,17 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -96,27 +95,6 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - switches = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - switches.append(entity_conf) - - return switches - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -127,33 +105,6 @@ def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, return option_config -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - switches = [] - - 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}" - - switches.append( - SwitchTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(switches) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -161,27 +112,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSwitchEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SWITCH_DOMAIN, + config, + StateSwitchEntity, + TriggerSwitchEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SWITCHES, ) @@ -195,20 +135,22 @@ async def async_setup_entry( _options.pop("template_type") _options = rewrite_options_to_modern_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SwitchTemplate: +) -> StateSwitchEntity: """Create a preview switch.""" updated_config = rewrite_options_to_modern_conf(config) validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return SwitchTemplate(hass, validated_config, None) + return StateSwitchEntity(hass, validated_config, None) -class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): +class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3157a60347e..e404821e651 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping import contextlib -import itertools import logging from typing import Any, cast @@ -14,7 +13,6 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, @@ -137,42 +135,6 @@ TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, - CONF_FRIENDLY_NAME: CONF_NAME, -} - - -def rewrite_common_legacy_to_modern_conf( - hass: HomeAssistant, - entity_cfg: dict[str, Any], - extra_legacy_fields: dict[str, str] | None = None, -) -> dict[str, Any]: - """Rewrite legacy config.""" - entity_cfg = {**entity_cfg} - if extra_legacy_fields is None: - extra_legacy_fields = {} - - for from_key, to_key in itertools.chain( - LEGACY_FIELDS.items(), extra_legacy_fields.items() - ): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = Template(val, hass) - entity_cfg[to_key] = val - - if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): - entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass) - - return entity_cfg - - class _TemplateAttribute: """Attribute value linked to template result.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1fb5b89ead2..d9c416f4863 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -41,13 +41,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_attributes_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -72,7 +71,7 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, CONF_VALUE_TEMPLATE: CONF_STATE, @@ -125,82 +124,23 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - vacuums = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - vacuums.append(entity_conf) - - return vacuums - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - vacuums = [] - - 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}" - - vacuums.append( - TemplateVacuum( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(vacuums) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerVacuumEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + """Set up the Template vacuum.""" + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + VACUUM_DOMAIN, + config, + TemplateStateVacuumEntity, + TriggerVacuumEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_VACUUMS, ) @@ -350,7 +290,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self._attr_fan_speed = None -class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): +class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """A template vacuum component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index ee834e757a3..66ead388d5d 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,12 +31,7 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import ( - CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template @@ -52,11 +47,8 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import ( - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -138,33 +130,6 @@ 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, @@ -172,36 +137,19 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - 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 - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + WEATHER_DOMAIN, + config, + StateWeatherEntity, + TriggerWeatherEntity, + async_add_entities, + discovery_info, + {}, ) -class WeatherTemplate(TemplateEntity, WeatherEntity): +class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 75a9e2c9689..b30051a52d2 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -559,7 +559,7 @@ def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." - "BinarySensorTemplate._update_state" + "StateBinarySensorEntity._update_state" ) as _update_state: yield _update_state diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py new file mode 100644 index 00000000000..574c764ba28 --- /dev/null +++ b/tests/components/template/test_helpers.py @@ -0,0 +1,344 @@ +"""The tests for template helpers.""" + +import pytest + +from homeassistant.components.template.alarm_control_panel import ( + LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, +) +from homeassistant.components.template.binary_sensor import ( + LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.button import StateButtonEntity +from homeassistant.components.template.cover import LEGACY_FIELDS as COVER_LEGACY_FIELDS +from homeassistant.components.template.fan import LEGACY_FIELDS as FAN_LEGACY_FIELDS +from homeassistant.components.template.helpers import ( + async_setup_template_platform, + rewrite_legacy_to_modern_config, + rewrite_legacy_to_modern_configs, +) +from homeassistant.components.template.light import LEGACY_FIELDS as LIGHT_LEGACY_FIELDS +from homeassistant.components.template.lock import LEGACY_FIELDS as LOCK_LEGACY_FIELDS +from homeassistant.components.template.sensor import ( + LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.switch import ( + LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, +) +from homeassistant.components.template.vacuum import ( + LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.template import Template + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + LOCK_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LOCK_LEGACY_FIELDS, + "code_format_template", + "code_format", + "{{ 'some format' }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of single legacy template to modern template.""" + config = { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + altered_configs = rewrite_legacy_to_modern_config(hass, config, legacy_fields) + + assert { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } == altered_configs + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + ALARM_CONTROL_PANEL_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + BINARY_SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "position_template", + "position", + "{{ 100 }}", + ), + ( + COVER_LEGACY_FIELDS, + "tilt_template", + "tilt", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "direction_template", + "direction", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "oscillating_template", + "oscillating", + "{{ True }}", + ), + ( + FAN_LEGACY_FIELDS, + "percentage_template", + "percentage", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "preset_mode_template", + "preset_mode", + "{{ 'foo' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "level_template", + "level", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ( + SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + SWITCH_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "battery_level_template", + "battery_level", + "{{ 100 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "fan_speed_template", + "fan_speed", + "{{ 7 }}", + ), + ], +) +async def test_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +@pytest.mark.parametrize( + "legacy_fields", + [ + BINARY_SENSOR_LEGACY_FIELDS, + SENSOR_LEGACY_FIELDS, + ], +) +async def test_friendly_name_template_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, +) -> None: + """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" + config = { + "foo": { + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + "friendly_name_template": "{{ 'foo bar' }}", + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + "name": Template("{{ 'foo bar' }}", hass), + } + ] == altered_configs + + +async def test_platform_not_ready( + hass: HomeAssistant, +) -> None: + """Test async_setup_template_platform raises PlatformNotReady when trigger object is None.""" + with pytest.raises(PlatformNotReady): + await async_setup_template_platform( + hass, + "button", + {}, + StateButtonEntity, + None, + None, + {"coordinator": None, "entities": []}, + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index eaa1708aea7..bfffd0911a9 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) -from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -29,7 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle @@ -289,127 +287,6 @@ TEST_UNIQUE_ID_CONFIG = { } -@pytest.mark.parametrize( - ("old_attr", "new_attr", "attr_template"), - [ - ( - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "rgb_template", - "rgb", - "{{ (255,255,255) }}", - ), - ( - "rgbw_template", - "rgbw", - "{{ (255,255,255,255) }}", - ), - ( - "rgbww_template", - "rgbww", - "{{ (255,255,255,255,255) }}", - ), - ( - "effect_list_template", - "effect_list", - "{{ ['a', 'b'] }}", - ), - ( - "effect_template", - "effect", - "{{ 'a' }}", - ), - ( - "level_template", - "level", - "{{ 255 }}", - ), - ( - "max_mireds_template", - "max_mireds", - "{{ 255 }}", - ), - ( - "min_mireds_template", - "min_mireds", - "{{ 255 }}", - ), - ( - "supports_transition_template", - "supports_transition", - "{{ True }}", - ), - ( - "temperature_template", - "temperature", - "{{ 255 }}", - ), - ( - "white_value_template", - "white_value", - "{{ 255 }}", - ), - ( - "hs_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "color_template", - "hs", - "{{ (255, 255) }}", - ), - ], -) -async def test_legacy_to_modern_config( - hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str -) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "unique_id": "foo-bar-light", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": { - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "turn_on": { - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "unique_id": "foo-bar-light", - new_attr: Template(attr_template, hass), - } - ] == altered_configs - - async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index c6ed303af7b..2e2fb5e8093 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -7,7 +7,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -18,7 +17,6 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle, async_get_flow_preview_state @@ -306,37 +304,6 @@ async def setup_single_attribute_optimistic_switch( ) -async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "value_template": "{{ 1 == 1 }}", - "unique_id": "foo-bar-switch", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - **SWITCH_ACTIONS, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": SWITCH_TURN_OFF, - "turn_on": SWITCH_TURN_ON, - "unique_id": "foo-bar-switch", - "state": Template("{{ 1 == 1 }}", hass), - } - ] == altered_configs - - @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @pytest.mark.parametrize( "style", From e2cc51f21def72ef5dbf9872119298147d7e0f41 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 15 Jul 2025 08:51:08 +0200 Subject: [PATCH 0225/1118] Allow AI Task to handle camera attachments (#148753) --- homeassistant/components/ai_task/entity.py | 7 +- .../components/ai_task/manifest.json | 1 + homeassistant/components/ai_task/task.py | 95 +++++++++++++++---- .../components/conversation/chat_log.py | 3 - tests/components/ai_task/test_init.py | 1 - tests/components/ai_task/test_task.py | 88 +++++++++++++++++ 6 files changed, 167 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 420777ce5c3..4c5cd186943 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -13,7 +13,7 @@ from homeassistant.components.conversation import ( ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import llm -from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.chat_session import ChatSession from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity): @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, + session: ChatSession, task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( - async_get_chat_session(self.hass) as session, async_get_chat_log( self.hass, session, @@ -88,12 +88,13 @@ class AITaskEntity(RestoreEntity): @final async def internal_async_generate_data( self, + session: ChatSession, task: GenDataTask, ) -> GenDataTaskResult: """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task) as chat_log: return await self._async_generate_data(task, chat_log) async def _async_generate_data( diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index c3e33e7d411..ea377ffa671 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -1,6 +1,7 @@ { "domain": "ai_task", "name": "AI Task", + "after_dependencies": ["camera"], "codeowners": ["@home-assistant/core"], "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index bb57a89203e..3cc43f8c07a 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,17 +3,32 @@ from __future__ import annotations from dataclasses import dataclass +import mimetypes +from pathlib import Path +import tempfile from typing import Any import voluptuous as vol -from homeassistant.components import conversation, media_source -from homeassistant.core import HomeAssistant +from homeassistant.components import camera, conversation, media_source +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.chat_session import async_get_chat_session from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +def _save_camera_snapshot(image: camera.Image) -> Path: + """Save camera snapshot to temp file.""" + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=mimetypes.guess_extension(image.content_type, False), + delete=False, + ) as temp_file: + temp_file.write(image.content) + return Path(temp_file.name) + + async def async_generate_data( hass: HomeAssistant, *, @@ -40,41 +55,79 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[conversation.Attachment] | None = None + resolved_attachments: list[conversation.Attachment] = [] + created_files: list[Path] = [] - if attachments: - if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: - raise HomeAssistantError( - f"AI Task entity {entity_id} does not support attachments" + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + for attachment in attachments or []: + media_content_id = attachment["media_content_id"] + + # Special case for camera media sources + if media_content_id.startswith("media-source://camera/"): + # Extract entity_id from the media content ID + entity_id = media_content_id.removeprefix("media-source://camera/") + + # Get snapshot from camera + image = await camera.async_get_image(hass, entity_id) + + temp_filename = await hass.async_add_executor_job( + _save_camera_snapshot, image ) + created_files.append(temp_filename) - resolved_attachments = [] - - for attachment in attachments: - media = await media_source.async_resolve_media( - hass, attachment["media_content_id"], None + resolved_attachments.append( + conversation.Attachment( + media_content_id=media_content_id, + mime_type=image.content_type, + path=temp_filename, + ) ) + else: + # Handle regular media sources + media = await media_source.async_resolve_media(hass, media_content_id, None) if media.path is None: raise HomeAssistantError( "Only local attachments are currently supported" ) resolved_attachments.append( conversation.Attachment( - media_content_id=attachment["media_content_id"], - url=media.url, + media_content_id=media_content_id, mime_type=media.mime_type, path=media.path, ) ) - return await entity.internal_async_generate_data( - GenDataTask( - name=task_name, - instructions=instructions, - structure=structure, - attachments=resolved_attachments, + with async_get_chat_session(hass) as session: + if created_files: + + def cleanup_files() -> None: + """Cleanup temporary files.""" + for file in created_files: + file.unlink(missing_ok=True) + + @callback + def cleanup_files_callback() -> None: + """Cleanup temporary files.""" + hass.async_add_executor_job(cleanup_files) + + session.async_on_cleanup(cleanup_files_callback) + + return await entity.internal_async_generate_data( + session, + GenDataTask( + name=task_name, + instructions=instructions, + structure=structure, + attachments=resolved_attachments or None, + ), ) - ) @dataclass(slots=True) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index e8ec66afa76..8d739b6267d 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -147,9 +147,6 @@ class Attachment: media_content_id: str """Media content ID of the attachment.""" - url: str - """URL of the attachment.""" - mime_type: str """MIME type of the attachment.""" diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 19f73045532..09ee926c187 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -117,7 +117,6 @@ async def test_generate_data_service( for msg_attachment, attachment in zip( msg_attachments, task.attachments or [], strict=False ): - assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] assert attachment.path == Path("media.mp4") diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index b11d96823cc..7eb75b62bb0 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -1,18 +1,26 @@ """Test tasks for the AI Task integration.""" +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data +from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -154,3 +162,83 @@ async def test_generate_data_attachments_not_supported( } ], ) + + +async def test_generate_data_mixed_attachments( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with both camera and regular media source attachments.""" + with ( + patch( + "homeassistant.components.camera.async_get_image", + return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"), + ) as mock_get_image, + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.mp4", + mime_type="video/mp4", + path=Path("/media/test.mp4"), + ), + ) as mock_resolve_media, + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Analyze these files", + attachments=[ + { + "media_content_id": "media-source://camera/camera.front_door", + "media_content_type": "image/jpeg", + }, + { + "media_content_id": "media-source://media_player/video.mp4", + "media_content_type": "video/mp4", + }, + ], + ) + + # Verify both methods were called + mock_get_image.assert_called_once_with(hass, "camera.front_door") + mock_resolve_media.assert_called_once_with( + hass, "media-source://media_player/video.mp4", None + ) + + # Check attachments + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.attachments is not None + assert len(task.attachments) == 2 + + # Check camera attachment + camera_attachment = task.attachments[0] + assert ( + camera_attachment.media_content_id == "media-source://camera/camera.front_door" + ) + assert camera_attachment.mime_type == "image/jpeg" + assert isinstance(camera_attachment.path, Path) + assert camera_attachment.path.suffix == ".jpg" + + # Verify camera snapshot content + assert camera_attachment.path.exists() + content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) + assert content == b"fake_camera_jpeg" + + # Trigger clean up + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + await hass.async_block_till_done() + + # Verify the temporary file cleaned up + assert not camera_attachment.path.exists() + + # Check regular media attachment + media_attachment = task.attachments[1] + assert media_attachment.media_content_id == "media-source://media_player/video.mp4" + assert media_attachment.mime_type == "video/mp4" + assert media_attachment.path == Path("/media/test.mp4") From 5e883cfb129859f06b54fb282756abbdadd50557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jul 2025 21:03:49 -1000 Subject: [PATCH 0226/1118] Fix flaky nuki tests by preventing teardown race condition (#148795) --- tests/components/nuki/__init__.py | 51 +++++++++++---------- tests/components/nuki/conftest.py | 13 ++++++ tests/components/nuki/test_binary_sensor.py | 4 +- tests/components/nuki/test_lock.py | 4 +- tests/components/nuki/test_sensor.py | 4 +- 5 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 tests/components/nuki/conftest.py diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py index 4f5728003fc..307ff080d71 100644 --- a/tests/components/nuki/__init__.py +++ b/tests/components/nuki/__init__.py @@ -14,28 +14,33 @@ from tests.common import ( ) -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_nuki_requests: requests_mock.Mocker +) -> MockConfigEntry: """Mock integration setup.""" - with requests_mock.Mocker() as mock: - # Mocking authentication endpoint - mock.get("http://1.1.1.1:8080/info", json=MOCK_INFO) - mock.get( - "http://1.1.1.1:8080/list", - json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), - ) - mock.get( - "http://1.1.1.1:8080/callback/list", - json=await async_load_json_object_fixture( - hass, "callback_list.json", DOMAIN - ), - ) - mock.get( - "http://1.1.1.1:8080/callback/add", - json=await async_load_json_object_fixture( - hass, "callback_add.json", DOMAIN - ), - ) - entry = await setup_nuki_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # Mocking authentication endpoint + mock_nuki_requests.get("http://1.1.1.1:8080/info", json=MOCK_INFO) + mock_nuki_requests.get( + "http://1.1.1.1:8080/list", + json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), + ) + callback_list_data = await async_load_json_object_fixture( + hass, "callback_list.json", DOMAIN + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/list", + json=callback_list_data, + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/add", + json=await async_load_json_object_fixture(hass, "callback_add.json", DOMAIN), + ) + # Mock the callback remove endpoint for teardown + mock_nuki_requests.delete( + requests_mock.ANY, + json={"success": True}, + ) + entry = await setup_nuki_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/nuki/conftest.py b/tests/components/nuki/conftest.py new file mode 100644 index 00000000000..624a5cafb9e --- /dev/null +++ b/tests/components/nuki/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures for nuki tests.""" + +from collections.abc import Generator + +import pytest +import requests_mock + + +@pytest.fixture +def mock_nuki_requests() -> Generator[requests_mock.Mocker]: + """Mock nuki HTTP requests.""" + with requests_mock.Mocker() as mock: + yield mock diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 11507100aae..20551a66307 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -19,9 +20,10 @@ async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test binary sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index fc2d9d1cba8..6d8c3cc43fc 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_locks( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test locks.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.LOCK]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index 69a0aec56f7..d03fe7f0da6 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From 7d7767c93a35c580ff145d5a50f62855b94264e8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 15 Jul 2025 17:21:00 +1000 Subject: [PATCH 0227/1118] Bump Tesla Fleet API to 1.2.2 (#148776) --- 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 4c92e0bd222..cf86fbeb4f9 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.2.0"] + "requirements": ["tesla-fleet-api==1.2.2"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index f58783e04a4..d12cf278d59 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.2.0", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c0cbc2ea431..26f26990d58 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.2.0"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53bc939f588..ee5e5b1e5df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2907,7 +2907,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18908ffe97..f7d07254799 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From f6aa4aa788165bfad08c792cb1fd9c927d44c134 Mon Sep 17 00:00:00 2001 From: Max Velitchko Date: Tue, 15 Jul 2025 01:14:26 -0700 Subject: [PATCH 0228/1118] Bump amcrest to 1.9.9 (#148769) --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 7d8f8f9e6c8..85e37b0df64 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["amcrest"], "quality_scale": "legacy", - "requirements": ["amcrest==1.9.8"] + "requirements": ["amcrest==1.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee5e5b1e5df..10abfedaad0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ altruistclient==0.1.1 amberelectric==2.0.12 # homeassistant.components.amcrest -amcrest==1.9.8 +amcrest==1.9.9 # homeassistant.components.androidtv androidtv[async]==0.0.75 From 41e261096aa30160ff7348045ed3984da4530910 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:34:16 +0200 Subject: [PATCH 0229/1118] Use suggested unit of measurement in Tuya (#148599) --- homeassistant/components/tuya/const.py | 11 ----- homeassistant/components/tuya/number.py | 8 ++-- homeassistant/components/tuya/sensor.py | 47 +++++++++++++++---- .../tuya/snapshots/test_sensor.ambr | 12 +++++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index f9377114e47..61da1239554 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -417,8 +416,6 @@ class UnitOfMeasurement: device_classes: set[str] aliases: set[str] = field(default_factory=set) - conversion_unit: str | None = None - conversion_fn: Callable[[float], float] | None = None # A tuple of available units of measurements we can work with. @@ -458,8 +455,6 @@ UNITS = ( SensorDeviceClass.CO, SensorDeviceClass.CO2, }, - conversion_unit=CONCENTRATION_PARTS_PER_MILLION, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfElectricCurrent.AMPERE, @@ -470,8 +465,6 @@ UNITS = ( unit=UnitOfElectricCurrent.MILLIAMPERE, aliases={"ma", "milliampere"}, device_classes={SensorDeviceClass.CURRENT}, - conversion_unit=UnitOfElectricCurrent.AMPERE, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfEnergy.WATT_HOUR, @@ -527,8 +520,6 @@ UNITS = ( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, - conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - conversion_fn=lambda x: x * 1000, ), UnitOfMeasurement( unit=UnitOfPower.WATT, @@ -596,8 +587,6 @@ UNITS = ( unit=UnitOfElectricPotential.MILLIVOLT, aliases={"mv", "millivolt"}, device_classes={SensorDeviceClass.VOLTAGE}, - conversion_unit=UnitOfElectricPotential.VOLT, - conversion_fn=lambda x: x / 1000, ), ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index b5b8437ea8b..cb248d42739 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -382,20 +382,18 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b45b8214bff..9caf642d403 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -14,6 +14,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -98,6 +100,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -112,6 +115,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -164,6 +168,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -181,6 +186,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -192,6 +198,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -278,18 +285,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CO_VALUE, translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -418,6 +428,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -432,6 +443,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -472,6 +484,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -489,12 +502,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -506,6 +521,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, @@ -518,6 +534,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, @@ -583,6 +600,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -597,6 +615,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -613,6 +632,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.TEMP, @@ -637,6 +657,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="concentration_carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_TIME, @@ -685,6 +706,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -724,6 +746,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -747,6 +770,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, @@ -759,12 +783,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm1", device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -945,6 +971,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -959,6 +986,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1004,12 +1032,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -1057,6 +1087,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1071,6 +1102,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -1097,6 +1129,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1113,6 +1146,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1415,20 +1449,18 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> StateType: @@ -1450,10 +1482,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): - scaled_value = self._type_data.scale_value(value) - if self._uom and self._uom.conversion_fn is not None: - return self._uom.conversion_fn(scaled_value) - return scaled_value + return self._type_data.scale_value(value) # Unexpected enum value if ( diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 3704aa4f067..f63c75567ef 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -387,6 +387,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -499,6 +502,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -555,6 +561,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -667,6 +676,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, From e1f15dac3950ddbf50ea794ed9df33e58a1bf436 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:39:53 +0200 Subject: [PATCH 0230/1118] Add Sensor platform to NASweb integration (#133063) Co-authored-by: Erik Montnemery --- homeassistant/components/nasweb/__init__.py | 2 +- homeassistant/components/nasweb/const.py | 1 + .../components/nasweb/coordinator.py | 20 +- homeassistant/components/nasweb/icons.json | 15 ++ homeassistant/components/nasweb/sensor.py | 189 ++++++++++++++++++ homeassistant/components/nasweb/strings.json | 12 ++ 6 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/nasweb/icons.json create mode 100644 homeassistant/components/nasweb/sensor.py diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py index 1992cc41c75..43998ef43b3 100644 --- a/homeassistant/components/nasweb/__init__.py +++ b/homeassistant/components/nasweb/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL from .coordinator import NASwebCoordinator from .nasweb_data import NASwebData -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] NASWEB_CONFIG_URL = "https://{host}/page" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py index ec750c90c8c..9150785d3bb 100644 --- a/homeassistant/components/nasweb/const.py +++ b/homeassistant/components/nasweb/const.py @@ -1,6 +1,7 @@ """Constants for the NASweb integration.""" DOMAIN = "nasweb" +KEY_TEMP_SENSOR = "temp_sensor" MANUFACTURER = "chomtech.pl" STATUS_UPDATE_MAX_TIME_INTERVAL = 60 SUPPORT_EMAIL = "support@chomtech.eu" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py index 90dca0f3022..2865bffe9a5 100644 --- a/homeassistant/components/nasweb/coordinator.py +++ b/homeassistant/components/nasweb/coordinator.py @@ -11,16 +11,19 @@ from typing import Any from aiohttp.web import Request, Response from webio_api import WebioAPI -from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE +from webio_api.const import KEY_DEVICE_SERIAL, KEY_TYPE, TYPE_STATUS_UPDATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol -from .const import STATUS_UPDATE_MAX_TIME_INTERVAL +from .const import KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL _LOGGER = logging.getLogger(__name__) +KEY_INPUTS = "inputs" +KEY_OUTPUTS = "outputs" + class NotificationCoordinator: """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" @@ -96,8 +99,11 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): self._job = HassJob(self._handle_max_update_interval, job_name) self._unsub_last_update_check: CALLBACK_TYPE | None = None self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - data: dict[str, Any] = {} - data[KEY_OUTPUTS] = self.webio_api.outputs + data: dict[str, Any] = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(data) def is_connection_confirmed(self) -> bool: @@ -187,5 +193,9 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): async def process_status_update(self, new_status: dict) -> None: """Process status update from NASweb.""" self.webio_api.update_device_status(new_status) - new_data = {KEY_OUTPUTS: self.webio_api.outputs} + new_data = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/icons.json b/homeassistant/components/nasweb/icons.json new file mode 100644 index 00000000000..0055bf2296a --- /dev/null +++ b/homeassistant/components/nasweb/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "sensor_input": { + "default": "mdi:help-circle-outline", + "state": { + "tamper": "mdi:lock-alert", + "active": "mdi:alert", + "normal": "mdi:shield-check-outline", + "problem": "mdi:alert-circle" + } + } + } + } +} diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py new file mode 100644 index 00000000000..eb342d7ce92 --- /dev/null +++ b/homeassistant/components/nasweb/sensor.py @@ -0,0 +1,189 @@ +"""Platform for NASweb sensors.""" + +from __future__ import annotations + +import logging +import time + +from webio_api import Input as NASwebInput, TempSensor + +from homeassistant.components.sensor import ( + DOMAIN as DOMAIN_SENSOR, + SensorDeviceClass, + SensorEntity, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL + +SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" +STATE_UNDEFINED = "undefined" +STATE_TAMPER = "tamper" +STATE_ACTIVE = "active" +STATE_NORMAL = "normal" +STATE_PROBLEM = "problem" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up Sensor platform.""" + coordinator = config.runtime_data + current_inputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_inputs: dict[int, NASwebInput] = { + entry.index: entry for entry in coordinator.webio_api.inputs + } + added = {i for i in received_inputs if i not in current_inputs} + removed = {i for i in current_inputs if i not in received_inputs} + entities_to_add: list[InputStateSensor] = [] + for index in added: + webio_input = received_inputs[index] + if not isinstance(webio_input, NASwebInput): + _LOGGER.error("Cannot create InputStateSensor without NASwebInput") + continue + new_input = InputStateSensor(coordinator, webio_input) + entities_to_add.append(new_input) + current_inputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + for index in removed: + unique_id = f"{DOMAIN}.{config.unique_id}.input.{index}" + if entity_id := entity_registry.async_get_entity_id( + DOMAIN_SENSOR, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + current_inputs.remove(index) + else: + _LOGGER.warning("Failed to remove old input: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + nasweb_temp_sensor = coordinator.data[KEY_TEMP_SENSOR] + temp_sensor = TemperatureSensor(coordinator, nasweb_temp_sensor) + async_add_entities([temp_sensor]) + + +class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity): + """Base class providing common functionality.""" + + def __init__(self, coordinator: BaseDataUpdateCoordinatorProtocol) -> None: + """Initialize base sensor.""" + super().__init__(coordinator) + self._attr_available = False + self._attr_has_entity_name = True + self._attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + def _set_attr_available( + self, entity_last_update: float, available: bool | None + ) -> None: + if ( + self.coordinator.last_update is None + or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = available if available is not None else False + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + +class InputStateSensor(BaseSensorEntity): + """Entity representing NASweb input.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options: list[str] = [ + STATE_UNDEFINED, + STATE_TAMPER, + STATE_ACTIVE, + STATE_NORMAL, + STATE_PROBLEM, + ] + _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_input: NASwebInput, + ) -> None: + """Initialize InputStateSensor entity.""" + super().__init__(coordinator) + self._input = nasweb_input + self._attr_native_value: str | None = None + self._attr_translation_placeholders = {"index": f"{nasweb_input.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._input.webio_serial}.input.{self._input.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._input.webio_serial)}, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._input.state is None or self._input.state in self._attr_options: + self._attr_native_value = self._input.state + else: + _LOGGER.warning("Received unrecognized input state: %s", self._input.state) + self._attr_native_value = None + self._set_attr_available(self._input.last_update, self._input.available) + self.async_write_ha_state() + + +class TemperatureSensor(BaseSensorEntity): + """Entity representing NASweb temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_temp_sensor: TempSensor, + ) -> None: + """Initialize TemperatureSensor entity.""" + super().__init__(coordinator) + self._temp_sensor = nasweb_temp_sensor + self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._temp_sensor.webio_serial)} + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self._temp_sensor.value + self._set_attr_available( + self._temp_sensor.last_update, self._temp_sensor.available + ) + self.async_write_ha_state() diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 8b93ea10d79..2e1ea55ffcb 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -45,6 +45,18 @@ "switch_output": { "name": "Relay Switch {index}" } + }, + "sensor": { + "sensor_input": { + "name": "Input {index}", + "state": { + "undefined": "Undefined", + "tamper": "Tamper", + "active": "Active", + "normal": "Normal", + "problem": "Problem" + } + } } } } From 4f938d032d02265a5a464ddf6b3b16de89b6a4d6 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 15 Jul 2025 02:45:55 -0600 Subject: [PATCH 0231/1118] Bump elevenlabs to 2.3.0 (#147224) --- .../components/elevenlabs/__init__.py | 3 +- .../components/elevenlabs/config_flow.py | 20 ++-- homeassistant/components/elevenlabs/const.py | 2 - .../components/elevenlabs/manifest.json | 2 +- .../components/elevenlabs/strings.json | 5 +- homeassistant/components/elevenlabs/tts.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/elevenlabs/conftest.py | 57 +++++++++++- .../components/elevenlabs/test_config_flow.py | 91 ++++++++++++++++++- tests/components/elevenlabs/test_tts.py | 87 ++++++++++-------- 11 files changed, 209 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index e5807fec67c..a930dea43ed 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS] async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: """Get ElevenLabs model from their API by the model_id.""" - models = await client.models.get_all() + models = await client.models.list() + for maybe_model in models: if maybe_model.model_id == model_id: return maybe_model diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227749bf82c..fc248235834 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -51,7 +49,8 @@ async def get_voices_models( httpx_client = get_async_client(hass) client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client) voices = (await client.voices.get_all()).voices - models = await client.models.get_all() + models = await client.models.list() + voices_dict = { voice.voice_id: voice.name for voice in sorted(voices, key=lambda v: v.name or "") @@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY]) - except ApiError: - errors["base"] = "invalid_api_key" + except ApiError as exc: + errors["base"] = "unknown" + details = getattr(exc, "body", {}).get("detail", {}) + if details: + status = details.get("status") + if status == "invalid_api_key": + errors["base"] = "invalid_api_key" else: return self.async_create_entry( title="ElevenLabs", @@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow): vol.Coerce(float), vol.Range(min=0, max=1), ), - vol.Optional( - CONF_OPTIMIZE_LATENCY, - default=self.config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), - ): vol.All(int, vol.Range(min=0, max=4)), vol.Optional( CONF_STYLE, default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE), diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 1de92f95e43..2629e62d2fc 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -7,7 +7,6 @@ CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" -CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency" CONF_STYLE = "style" CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" @@ -15,6 +14,5 @@ DOMAIN = "elevenlabs" DEFAULT_MODEL = "eleven_multilingual_v2" DEFAULT_STABILITY = 0.5 DEFAULT_SIMILARITY = 0.75 -DEFAULT_OPTIMIZE_LATENCY = 0 DEFAULT_STYLE = 0 DEFAULT_USE_SPEAKER_BOOST = True diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index eb6df09149a..f36a2383576 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.9.0"] + "requirements": ["elevenlabs==2.3.0"] } diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index 8b0205a9e9a..eb497f1a7a6 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "options": { @@ -32,14 +33,12 @@ "data": { "stability": "Stability", "similarity": "Similarity", - "optimize_streaming_latency": "Latency", "style": "Style", "use_speaker_boost": "Speaker boost" }, "data_description": { "stability": "Stability of the generated audio. Higher values lead to less emotional audio.", "similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.", - "optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.", "style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.", "use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice." } diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 61850837075..fc1a950d4b9 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( ATTR_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -75,9 +73,6 @@ async def async_setup_entry( config_entry.entry_id, config_entry.title, voice_settings, - config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), ) ] ) @@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): entry_id: str, title: str, voice_settings: VoiceSettings, - latency: int = 0, ) -> None: """Init ElevenLabs TTS service.""" self._client = client @@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): if voice_indices: self._voices.insert(0, self._voices.pop(voice_indices[0])) self._voice_settings = voice_settings - self._latency = latency # Entity attributes self._attr_unique_id = entry_id @@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): voice_id = options.get(ATTR_VOICE, self._default_voice_id) model = options.get(ATTR_MODEL, self._model.model_id) try: - audio = await self._client.generate( + audio = self._client.text_to_speech.convert( text=message, - voice=voice_id, - optimize_streaming_latency=self._latency, + voice_id=voice_id, voice_settings=self._voice_settings, - model=model, + model_id=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) + except ApiError as exc: _LOGGER.warning( "Error during processing of TTS request %s", exc, exc_info=True diff --git a/requirements_all.txt b/requirements_all.txt index 10abfedaad0..140932f5f52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -845,7 +845,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7d07254799..da9d5047723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -736,7 +736,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index 1c261e2947a..c47017b88e9 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -28,7 +28,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: def _client_mock(): client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS + return client_mock @@ -44,6 +45,10 @@ def mock_async_client() -> Generator[AsyncMock]: "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", new=mock_async_client, ), + patch( + "homeassistant.components.elevenlabs.tts.AsyncElevenLabs", + new=mock_async_client, + ), ): yield mock_async_client @@ -52,8 +57,12 @@ def mock_async_client() -> Generator[AsyncMock]: def mock_async_client_api_error() -> Generator[AsyncMock]: """Override async ElevenLabs client with ApiError side effect.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ApiError - client_mock.voices.get_all.side_effect = ApiError + api_error = ApiError() + api_error.body = { + "detail": {"status": "invalid_api_key", "message": "API key is invalid"} + } + client_mock.models.list.side_effect = api_error + client_mock.voices.get_all.side_effect = api_error with ( patch( @@ -68,11 +77,51 @@ def mock_async_client_api_error() -> Generator[AsyncMock]: yield mock_async_client +@pytest.fixture +def mock_async_client_voices_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "voices_unauthorized", + "message": "API is unauthorized for voices", + } + } + client_mock.voices.get_all.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + +@pytest.fixture +def mock_async_client_models_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "models_unauthorized", + "message": "API is unauthorized for models", + } + } + client_mock.models.list.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + @pytest.fixture def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.models.list.side_effect = ConnectError("Unknown") client_mock.voices.get_all.side_effect = ConnectError("Unknown") with ( patch( diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 7eeb0a6eb46..eccd5d49d92 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -7,14 +7,12 @@ import pytest from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -101,6 +99,94 @@ async def test_invalid_api_key( mock_setup_entry.assert_called_once() +async def test_voices_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_voices_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + +async def test_models_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_models_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + async def test_options_flow_init( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -166,7 +252,6 @@ async def test_options_flow_voice_settings_default( assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", - CONF_OPTIMIZE_LATENCY: DEFAULT_OPTIMIZE_LATENCY, CONF_SIMILARITY: DEFAULT_SIMILARITY, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index a63672cc85d..f25a03f2824 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -15,13 +15,11 @@ from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( ATTR_MODEL, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -44,6 +42,19 @@ from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +class FakeAudioGenerator: + """Mock audio generator for ElevenLabs TTS.""" + + def __aiter__(self): + """Mock async iterator for audio parts.""" + + async def _gen(): + yield b"audio-part-1" + yield b"audio-part-2" + + return _gen() + + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -74,12 +85,6 @@ def mock_similarity(): return DEFAULT_SIMILARITY / 2 -@pytest.fixture -def mock_latency(): - """Mock latency.""" - return (DEFAULT_OPTIMIZE_LATENCY + 1) % 5 # 0, 1, 2, 3, 4 - - @pytest.fixture(name="setup") async def setup_fixture( hass: HomeAssistant, @@ -98,6 +103,7 @@ async def setup_fixture( raise RuntimeError("Invalid setup fixture") await hass.async_block_till_done() + return mock_async_client @@ -114,10 +120,9 @@ def config_options_fixture() -> dict[str, Any]: @pytest.fixture(name="config_options_voice") -def config_options_voice_fixture(mock_similarity, mock_latency) -> dict[str, Any]: +def config_options_voice_fixture(mock_similarity) -> dict[str, Any]: """Return config options.""" return { - CONF_OPTIMIZE_LATENCY: mock_latency, CONF_SIMILARITY: mock_similarity, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, @@ -144,7 +149,7 @@ async def mock_config_entry_setup( config_entry.add_to_hass(hass) client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS with patch( "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock ): @@ -217,7 +222,10 @@ async def test_tts_service_speak( ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=DEFAULT_SIMILARITY, @@ -240,12 +248,11 @@ async def test_tts_service_speak( voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice=voice_id, - model=model_id, + voice_id=voice_id, + model_id=model_id, voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -287,7 +294,9 @@ async def test_tts_service_speak_lang_config( ) -> None: """Test service call say with other langcodes in the config.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -302,12 +311,11 @@ async def test_tts_service_speak_lang_config( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -337,8 +345,10 @@ async def test_tts_service_speak_error( ) -> None: """Test service call say with http response 400.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() - tts_entity._client.generate.side_effect = ApiError + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + tts_entity._client.text_to_speech.convert.side_effect = ApiError await hass.services.async_call( tts.DOMAIN, @@ -353,12 +363,11 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -396,18 +405,18 @@ async def test_tts_service_speak_voice_settings( tts_service: str, service_data: dict[str, Any], mock_similarity: float, - mock_latency: int, ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=mock_similarity, style=DEFAULT_STYLE, use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, ) - assert tts_entity._latency == mock_latency await hass.services.async_call( tts.DOMAIN, @@ -422,12 +431,11 @@ async def test_tts_service_speak_voice_settings( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice_id="voice2", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -457,7 +465,9 @@ async def test_tts_service_speak_without_options( ) -> None: """Test service call say with http response 200.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -472,12 +482,11 @@ async def test_tts_service_speak_without_options( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - optimize_streaming_latency=0, + voice_id="voice1", voice_settings=VoiceSettings( stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=True ), - model="model1", + model_id="model1", ) From db45f46c8a4f9473f15334bd1553aa0dd159902e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:14:47 +0200 Subject: [PATCH 0232/1118] Fan support in WiZ (#146440) --- CODEOWNERS | 4 +- homeassistant/components/wiz/__init__.py | 1 + homeassistant/components/wiz/fan.py | 139 +++++++++++ homeassistant/components/wiz/manifest.json | 2 +- tests/components/wiz/__init__.py | 26 +++ tests/components/wiz/snapshots/test_fan.ambr | 61 +++++ tests/components/wiz/test_fan.py | 232 +++++++++++++++++++ 7 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wiz/fan.py create mode 100644 tests/components/wiz/snapshots/test_fan.ambr create mode 100644 tests/components/wiz/test_fan.py diff --git a/CODEOWNERS b/CODEOWNERS index a6ab083e07d..c0bed7f100a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek -/homeassistant/components/wiz/ @sbidy -/tests/components/wiz/ @sbidy +/homeassistant/components/wiz/ @sbidy @arturpragacz +/tests/components/wiz/ @sbidy @arturpragacz /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck /homeassistant/components/wmspro/ @mback2k diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 43a9b863d20..39be4d9a387 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py new file mode 100644 index 00000000000..f826ee80b8b --- /dev/null +++ b/homeassistant/components/wiz/fan.py @@ -0,0 +1,139 @@ +"""WiZ integration fan platform.""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +from pywizlight.bulblibrary import BulbType, Features + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import WizConfigEntry +from .entity import WizEntity +from .models import WizData + +PRESET_MODE_BREEZE = "breeze" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WizConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WiZ Platform from config_flow.""" + if entry.runtime_data.bulb.bulbtype.features.fan: + async_add_entities([WizFanEntity(entry.runtime_data, entry.title)]) + + +class WizFanEntity(WizEntity, FanEntity): + """Representation of WiZ Light bulb.""" + + _attr_name = None + + # We want the implementation of is_on to be the same as in ToggleEntity, + # but it is being overridden in FanEntity, so we need to restore it here. + is_on: ClassVar = ToggleEntity.is_on + + def __init__(self, wiz_data: WizData, name: str) -> None: + """Initialize a WiZ fan.""" + super().__init__(wiz_data, name) + bulb_type: BulbType = self._device.bulbtype + features: Features = bulb_type.features + + supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + if features.fan_reverse: + supported_features |= FanEntityFeature.DIRECTION + if features.fan_breeze_mode: + supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = [PRESET_MODE_BREEZE] + + self._attr_supported_features = supported_features + self._attr_speed_count = bulb_type.fan_speed_range + + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + state = self._device.state + + self._attr_is_on = state.get_fan_state() > 0 + self._attr_percentage = ranged_value_to_percentage( + (1, self.speed_count), state.get_fan_speed() + ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + fan_mode = state.get_fan_mode() + self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None + if FanEntityFeature.DIRECTION in self.supported_features: + fan_reverse = state.get_fan_reverse() + self._attr_current_direction = None + if fan_reverse == 0: + self._attr_current_direction = DIRECTION_FORWARD + elif fan_reverse == 1: + self._attr_current_direction = DIRECTION_REVERSE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + # preset_mode == PRESET_MODE_BREEZE + await self._device.fan_set_state(mode=2) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage)) + await self._device.fan_set_state(mode=1, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode: int | None = None + speed: int | None = None + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + if preset_mode == PRESET_MODE_BREEZE: + mode = 2 + if percentage is not None: + speed = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + if mode is None: + mode = 1 + await self._device.fan_turn_on(mode=mode, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self._device.fan_turn_off(**kwargs) + await self.coordinator.async_request_refresh() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + reverse = 1 if direction == DIRECTION_REVERSE else 0 + await self._device.fan_set_state(reverse=reverse) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 2ae78a8af92..57671ecd007 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -1,7 +1,7 @@ { "domain": "wiz", "name": "WiZ", - "codeowners": ["@sbidy"], + "codeowners": ["@sbidy", "@arturpragacz"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index d84074e37d3..037b6a1dfbd 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -33,6 +33,10 @@ FAKE_STATE = PilotParser( "c": 0, "w": 0, "dimming": 100, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, } ) FAKE_IP = "1.1.1.1" @@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( white_channels=1, white_to_color_ratio=80, ) +FAKE_DIMMABLE_FAN = BulbType( + bulb_type=BulbClass.FANDIM, + name="ESP03_FANDIMS_31", + features=Features( + color=False, + color_tmp=False, + effect=True, + brightness=True, + dual_head=False, + fan=True, + fan_breeze_mode=True, + fan_reverse=True, + ), + kelvin_range=KelvinRange(max=2700, min=2700), + fw_version="1.31.32", + white_channels=1, + white_to_color_ratio=20, + fan_speed_range=6, +) async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: @@ -220,6 +243,9 @@ def _mocked_wizlight( bulb.async_close = AsyncMock() bulb.set_speed = AsyncMock() bulb.set_ratio = AsyncMock() + bulb.fan_set_state = AsyncMock() + bulb.fan_turn_on = AsyncMock() + bulb.fan_turn_off = AsyncMock() bulb.diagnostics = { "mocked": "mocked", "roomId": 123, diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr new file mode 100644 index 00000000000..2c6b235e78b --- /dev/null +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_entity[fan.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'breeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.mock_title', + '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': 'wiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'abcabcabcabc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'Mock Title', + 'percentage': 16, + 'percentage_step': 16.666666666666668, + 'preset_mode': None, + 'preset_modes': list([ + 'breeze', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/wiz/test_fan.py b/tests/components/wiz/test_fan.py new file mode 100644 index 00000000000..d15f083d431 --- /dev/null +++ b/tests/components/wiz/test_fan.py @@ -0,0 +1,232 @@ +"""Tests for fan platform.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.components.wiz.fan import PRESET_MODE_BREEZE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration + +from tests.common import snapshot_platform + +ENTITY_ID = "fan.mock_title" + +INITIAL_PARAMS = { + "mac": FAKE_MAC, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, +} + + +@patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN]) +async def test_entity( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test the fan entity.""" + entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +def _update_params( + params: dict[str, Any], + state: int | None = None, + mode: int | None = None, + speed: int | None = None, + reverse: int | None = None, +) -> dict[str, Any]: + """Get the parameters for the update.""" + if state is not None: + params["fanState"] = state + if mode is not None: + params["fanMode"] = mode + if speed is not None: + params["fanSpeed"] = speed + if reverse is not None: + params["fanRevrs"] = reverse + return params + + +async def test_turn_on_off(hass: HomeAssistant) -> None: + """Test turning the fan on and off.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": None, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_PRESET_MODE] is None + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_turn_off.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_fan_set_preset_mode(hass: HomeAssistant) -> None: + """Test setting the fan preset mode.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + +async def test_fan_set_percentage(hass: HomeAssistant) -> None: + """Test setting the fan percentage.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 50 + + +async def test_fan_set_direction(hass: HomeAssistant) -> None: + """Test setting the fan direction.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_REVERSE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 1} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_FORWARD}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 0} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD From 3d74d0270423c961a78a5e23aad2a3dd510fdc4f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:15:06 +0200 Subject: [PATCH 0233/1118] Update pytouchlinesl to 0.4.0 (#148801) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ab07ae770fd..5140584f7ff 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.3.0"] + "requirements": ["pytouchlinesl==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 140932f5f52..0b3e1361e81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2538,7 +2538,7 @@ pytomorrowio==0.3.6 pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da9d5047723..b822277d8c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From a6e1d968526e7ad1f6d5e0a4f77e98a58efdabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 15 Jul 2025 11:21:54 +0200 Subject: [PATCH 0234/1118] Update aioairzone-cloud to v0.6.13 (#148798) 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_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/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index ecc9634f36a..e185ed89106 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.12"] + "requirements": ["aioairzone-cloud==0.6.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b3e1361e81..742deadc2f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b822277d8c6..1a3d5730ec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 From b522bd5ef20746c5a516e1ecac75ff4ed0a3d848 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 15 Jul 2025 12:07:57 +0200 Subject: [PATCH 0235/1118] Get media player features elsewhere for jellyfin (#148805) --- homeassistant/components/jellyfin/media_player.py | 12 ++++++++++-- tests/components/jellyfin/fixtures/sessions.json | 2 +- .../jellyfin/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index e0fcc8a559b..b71c0bf93c9 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.media_player import ( @@ -21,6 +22,8 @@ from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -177,10 +180,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" commands: list[str] = self.capabilities.get("SupportedCommands", []) - controllable = self.capabilities.get("SupportsMediaControl", False) + _LOGGER.debug( + "Supported commands for device %s, client %s, %s", + self.device_name, + self.client_name, + commands, + ) features = MediaPlayerEntityFeature(0) - if controllable: + if "PlayMediaSource" in commands: features |= ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index db2b691dff0..9a8f93dc5bd 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -21,7 +21,7 @@ ], "Capabilities": { "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], + "SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"], "SupportsMediaControl": true, "SupportsContentUploading": true, "MessageCallbackUrl": "string", diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index 9d73ee6397c..0100c7618b7 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -182,6 +182,7 @@ 'SupportedCommands': list([ 'VolumeSet', 'Mute', + 'PlayMediaSource', ]), 'SupportsContentUploading': True, 'SupportsMediaControl': True, From 1cb278966c9b05a5588d784031d185287b0da80b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:15:19 +0200 Subject: [PATCH 0236/1118] Handle connection issues after websocket reconnected in homematicip_cloud (#147731) --- .../components/homematicip_cloud/hap.py | 63 ++++++++++++------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homematicip_cloud/test_device.py | 11 +++- .../components/homematicip_cloud/test_hap.py | 61 ++++++++++++++++-- 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index c42ebff200d..d66594da390 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -113,9 +113,7 @@ class HomematicipHAP: self._ws_close_requested = False self._ws_connection_closed = asyncio.Event() - self._retry_task: asyncio.Task | None = None - self._tries = 0 - self._accesspoint_connected = True + self._get_state_task: asyncio.Task | None = None self.hmip_device_by_entity_id: dict[str, Any] = {} self.reset_connection_listener: Callable | None = None @@ -161,17 +159,8 @@ class HomematicipHAP: """ if not self.home.connected: _LOGGER.error("HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False + self._ws_connection_closed.set() self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Now the HOME_CHANGED event has fired indicating the access - # point has reconnected to the cloud again. - # Explicitly getting an update as entity states might have - # changed during access point disconnect.""" - - job = self.hass.async_create_task(self.get_state()) - job.add_done_callback(self.get_state_finished) - self._accesspoint_connected = True @callback def async_create_entity(self, *args, **kwargs) -> None: @@ -185,20 +174,43 @@ class HomematicipHAP: await asyncio.sleep(30) await self.hass.config_entries.async_reload(self.config_entry.entry_id) + async def _try_get_state(self) -> None: + """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + + # Wait until WebSocket connection is established. + while not self.home.websocket_is_connected(): + await asyncio.sleep(2) + + delay = 8 + max_delay = 1500 + while True: + try: + await self.get_state() + break + except HmipConnectionError as err: + _LOGGER.warning( + "Get_state failed, retrying in %s seconds: %s", delay, err + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: - """Execute when get_state coroutine has finished.""" + """Execute when try_get_state coroutine has finished.""" try: future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error("Updating state after HMIP access point reconnect failed") - self.hass.async_create_task(self.home.disable_events()) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error updating state after HMIP access point reconnect: %s", err + ) + else: + _LOGGER.info( + "Updating state after HMIP access point reconnect finished successfully", + ) def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" @@ -222,8 +234,8 @@ class HomematicipHAP: async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() + if self._get_state_task is not None: + self._get_state_task.cancel() await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( @@ -247,7 +259,9 @@ class HomematicipHAP: """Handle websocket connected.""" _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): - await self.get_state() + self._get_state_task = self.hass.async_create_task(self._try_get_state()) + self._get_state_task.add_done_callback(self.get_state_finished) + self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: @@ -256,11 +270,12 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection.""" + """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", reason, ) + self._ws_connection_closed.set() async def get_hap( diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d5af2859873..036ffa286a3 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.6"] + "requirements": ["homematicip==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 742deadc2f8..8fe43a3198c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a3d5730ec5..d7e3da48a19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index aff698cd3d9..9dd537848fe 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -195,9 +195,14 @@ async def test_hap_reconnected( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNAVAILABLE - mock_hap._accesspoint_connected = False - await async_manipulate_test_data(hass, mock_hap.home, "connected", True) - await hass.async_block_till_done() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await async_manipulate_test_data(hass, mock_hap.home, "connected", True) + await mock_hap.ws_connected_handler() + await hass.async_block_till_done() + ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ae094f7dded..69078beafaf 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -242,7 +242,14 @@ async def test_get_state_after_disconnect( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "get_state") as mock_get_state: + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + hap.home = simple_mock_home + hap.home.websocket_is_connected = Mock(side_effect=[False, True]) + + with ( + patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, + patch.object(hap, "get_state") as mock_get_state, + ): assert not hap._ws_connection_closed.is_set() await hap.ws_connected_handler() @@ -250,8 +257,54 @@ async def test_get_state_after_disconnect( await hap.ws_disconnected_handler() assert hap._ws_connection_closed.is_set() - await hap.ws_connected_handler() - mock_get_state.assert_called_once() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await hap.ws_connected_handler() + mock_get_state.assert_called_once() + + assert not hap._ws_connection_closed.is_set() + hap.home.websocket_is_connected.assert_called() + mock_sleep.assert_awaited_with(2) + + +async def test_try_get_state_exponential_backoff() -> None: + """Test _try_get_state waits for websocket connection.""" + + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + hap.home.websocket_is_connected = Mock(return_value=True) + + hap.get_state = AsyncMock( + side_effect=[HmipConnectionError, HmipConnectionError, True] + ) + + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + await hap._try_get_state() + + assert mock_sleep.mock_calls[0].args[0] == 8 + assert mock_sleep.mock_calls[1].args[0] == 16 + assert hap.get_state.call_count == 3 + + +async def test_try_get_state_handle_exception() -> None: + """Test _try_get_state handles exceptions.""" + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + + expected_exception = Exception("Connection error") + future = AsyncMock() + future.result = Mock(side_effect=expected_exception) + + with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger: + hap.get_state_finished(future) + + mock_logger.error.assert_called_once_with( + "Error updating state after HMIP access point reconnect: %s", expected_exception + ) async def test_async_connect( From ab187f39c2b63e434013a587e37517186bdef4fb Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:16:07 +0200 Subject: [PATCH 0237/1118] Add support for HmIP-RGBW and HmIP-LSC in homematicip_cloud integration (#148639) --- .../components/homematicip_cloud/light.py | 77 +++- .../fixtures/homematicip_cloud.json | 370 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_light.py | 76 ++++ 4 files changed, 523 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index d5175e6e647..1e602cd09c2 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -2,13 +2,20 @@ from __future__ import annotations +import logging from typing import Any -from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import ( + DeviceType, + FunctionalChannelType, + OpticalSignalBehaviour, + RGBColorState, +) from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, BrandSwitchNotificationLight, + Device, Dimmer, DinRailDimmer3, FullFlushDimmer, @@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +_logger = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +52,14 @@ async def async_setup_entry( """Set up the HomematicIP Cloud lights from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + entities.extend( + HomematicipLightHS(hap, d, ch.index) + for d in hap.home.devices + for ch in d.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL + ) + for device in hap.home.devices: if ( isinstance(device, SwitchMeasuring) @@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): await self._device.turn_off_async() +class HomematicipLightHS(HomematicipGenericEntity, LightEntity): + """Representation of the HomematicIP light with HS color mode.""" + + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + + def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: + """Initialize the light entity.""" + super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.functional_channel.on + + @property + def brightness(self) -> int | None: + """Return the current brightness.""" + return int(self.functional_channel.dimLevel * 255.0) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + if ( + self.functional_channel.hue is None + or self.functional_channel.saturationLevel is None + ): + return None + return ( + self.functional_channel.hue, + self.functional_channel.saturationLevel * 100.0, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + + hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0)) + hue = hs_color[0] % 360.0 + saturation = hs_color[1] / 100.0 + dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2) + + if ATTR_HS_COLOR not in kwargs: + hue = self.functional_channel.hue + saturation = self.functional_channel.saturationLevel + + if ATTR_BRIGHTNESS not in kwargs: + # If no brightness is set, use the current brightness + dim_level = self.functional_channel.dimLevel or 1.0 + + await self.functional_channel.set_hue_saturation_dim_level_async( + hue=hue, saturation_level=saturation, dim_level=dim_level + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.functional_channel.set_switch_state_async(on=False) + + class HomematicipLightMeasuring(HomematicipLight): """Representation of the HomematicIP measuring light.""" diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index c378190d00c..c9eab0cf4f5 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8566,6 +8566,376 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000RGBW2": { + "availableFirmwareVersion": "1.0.62", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "fastColorChangeSupported": true, + "firmwareVersion": "1.0.62", + "firmwareVersionInteger": 65598, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000RGBW2", + "deviceOperationMode": "UNIVERSAL_LIGHT_1_RGB", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": null, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": null, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -50, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": false, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": true, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": false, + "IOptionalFeatureDeviceWaterError": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureLowBat": false, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": null, + "valveWaterError": null + }, + "1": { + "channelActive": true, + "channelRole": "UNIVERSAL_LIGHT_ACTUATOR", + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": false, + "dimLevel": 0.68, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000061"], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": 120, + "humanCentricLightActive": false, + "index": 1, + "label": "", + "lampFailure": null, + "lightSceneId": 1, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": true, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": 0.8, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLightGroupActuatorChannel": true, + "IFeatureLightProfileActuatorChannel": true, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": true, + "IOptionalFeatureLightScene": true, + "IOptionalFeatureLightSceneWithShortTimes": true, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": true, + "IOptionalFeaturePowerUpHueSaturationValue": true, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "2": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 2, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "3": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 3, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "4": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 4, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000RGBW2", + "label": "RGBW Controller", + "lastStatusUpdate": 1749973334235, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 1, + "measuredAttributes": {}, + "modelId": 462, + "modelType": "HmIP-RGBW", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", + "type": "RGBW_DIMMER", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 9dd537848fe..4fb9f9eede8 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 331 + assert len(mock_hap.hmip_device_by_entity_id) == 335 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index b929bd337cc..85106f2d987 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -600,3 +600,79 @@ async def test_hmip_din_rail_dimmer_3_channel3( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF assert not ha_state.attributes.get(ATTR_BRIGHTNESS) + + +async def test_hmip_light_hs( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipLight with HS color mode.""" + entity_id = "light.rgbw_controller_channel1" + entity_name = "RGBW Controller Channel1" + device_model = "HmIP-RGBW" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["RGBW Controller"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_ON + assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [240.0, 100.0]}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 240.0, + "saturation_level": 1.0, + "dim_level": 0.68, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [220.0, 80.0], ATTR_BRIGHTNESS: 123}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 220.0, + "saturation_level": 0.8, + "dim_level": 0.48, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_BRIGHTNESS: 40}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": hmip_device.functionalChannels[1].hue, + "saturation_level": hmip_device.functionalChannels[1].saturationLevel, + "dim_level": 0.16, + } From 8256401f7f91a52d4d92c512267fb769eed75dc9 Mon Sep 17 00:00:00 2001 From: wuede Date: Tue, 15 Jul 2025 12:16:59 +0200 Subject: [PATCH 0238/1118] expose schedule id as an extra state attribute in Netatmo (#147076) --- homeassistant/components/netatmo/climate.py | 21 +++++++++++++------ homeassistant/components/netatmo/const.py | 1 + .../netatmo/snapshots/test_climate.ambr | 4 ++++ tests/components/netatmo/test_climate.py | 13 ++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f8f89ffd06b..a74ed630a4b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -38,6 +38,7 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, DATA_SCHEDULES, @@ -251,16 +252,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: + selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + self.home.entity_id + ].get(data["schedule_id"]) self._selected_schedule = getattr( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ), + selected_schedule, "name", None, ) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) + self.async_write_ha_state() self.data_handler.async_force_update(self._signal_name) # ignore other schedule events @@ -420,12 +427,14 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - self._selected_schedule = getattr( - self.home.get_selected_schedule(), "name", None - ) + selected_schedule = self.home.get_selected_schedule() + self._selected_schedule = getattr(selected_schedule, "name", None) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) if self.device_type == NA_VALVE: self._attr_extra_state_attributes[ATTR_HEATING_POWER_REQUEST] = ( diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index d69a62f37f9..d8ecc72ada7 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -95,6 +95,7 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_SELECTED_SCHEDULE_ID = "selected_schedule_id" ATTR_TARGET_TEMPERATURE = "target_temperature" ATTR_TIME_PERIOD = "time_period" diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 22a50213306..e5d5f477d34 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -147,6 +147,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -229,6 +230,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22, @@ -312,6 +314,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -396,6 +399,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 12, diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index f38e21021dc..0344ec8a7c1 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -681,6 +681,13 @@ async def test_service_schedule_thermostats( webhook_id = config_entry.data[CONF_WEBHOOK_ID] climate_entity_livingroom = "climate.livingroom" + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "591b54a2764ff4d50d8b5795" + ) + # Test setting a valid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule: await hass.services.async_call( @@ -707,6 +714,12 @@ async def test_service_schedule_thermostats( hass.states.get(climate_entity_livingroom).attributes["selected_schedule"] == "Winter" ) + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "b1b54a2f45795764f59d50d8" + ) # Test setting an invalid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: From c7aadcdd20544aa3842091b5b5c032bd8fa553b2 Mon Sep 17 00:00:00 2001 From: Alex Leversen <91166616+leversonic@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:35:20 -0400 Subject: [PATCH 0239/1118] Add file name/size sensors to OctoPrint integration (#148636) --- homeassistant/components/octoprint/sensor.py | 62 +++++++++++++- tests/components/octoprint/__init__.py | 16 +++- tests/components/octoprint/test_sensor.py | 82 ++++++++++++++----- .../{test_servics.py => test_services.py} | 0 4 files changed, 137 insertions(+), 23 deletions(-) rename tests/components/octoprint/{test_servics.py => test_services.py} (100%) diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 71db1d804c5..5594de48ff5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,6 +84,8 @@ async def async_setup_entry( OctoPrintJobPercentageSensor(coordinator, device_id), OctoPrintEstimatedFinishTimeSensor(coordinator, device_id), OctoPrintStartTimeSensor(coordinator, device_id), + OctoPrintFileNameSensor(coordinator, device_id), + OctoPrintFileSizeSensor(coordinator, device_id), ] async_add_entities(entities) @@ -262,3 +264,61 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success and self.coordinator.data["printer"] + + +class OctoPrintFileNameSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File", device_id) + + @property + def native_value(self) -> str | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.name or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.name + + +class OctoPrintFileSizeSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + _attr_device_class = SensorDeviceClass.DATA_SIZE + _attr_native_unit_of_measurement = UnitOfInformation.BYTES + _attr_suggested_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File Size", device_id) + + @property + def native_value(self) -> int | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.size or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.size diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index 3ddae7de587..3755b84a6f9 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,21 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {"file": {}}, + "job": { + "averagePrintTime": None, + "estimatedPrintTime": None, + "filament": None, + "file": { + "date": None, + "display": None, + "name": None, + "origin": None, + "path": None, + "size": None, + }, + "lastPrintTime": None, + "user": None, + }, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 87485e46807..3b0ed2ded0b 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from freezegun.api import FrozenDateTimeFactory +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,11 +24,7 @@ async def test_sensors( }, "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Printing", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) await init_integration(hass, "sensor", printer=printer, job=job) @@ -80,6 +77,21 @@ async def test_sensors( entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == "Test_File_Name.gcode" + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == "123.456789" + assert state.attributes.get("unit_of_measurement") == UnitOfInformation.MEGABYTES + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_no_target_temp( hass: HomeAssistant, @@ -106,11 +118,25 @@ async def test_sensors_no_target_temp( state = hass.states.get("sensor.octoprint_target_tool1_temp") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint target tool1 temp" entry = entity_registry.async_get("sensor.octoprint_target_tool1_temp") assert entry.unique_id == "target tool1 temp-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_paused( hass: HomeAssistant, @@ -125,24 +151,20 @@ async def test_sensors_paused( }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=printer, job=job) state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" @@ -154,11 +176,7 @@ async def test_sensors_printer_disconnected( entity_registry: er.EntityRegistry, ) -> None: """Test the underlying sensors.""" - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=None, job=job) @@ -171,21 +189,43 @@ async def test_sensors_printer_disconnected( state = hass.states.get("sensor.octoprint_current_state") assert state is not None - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE assert state.name == "OctoPrint Current State" entry = entity_registry.async_get("sensor.octoprint_current_state") assert entry.unique_id == "Current State-uuid" state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + + +def __standard_job(): + return { + "job": { + "averagePrintTime": 6500, + "estimatedPrintTime": 6000, + "filament": {"tool0": {"length": 3000, "volume": 7}}, + "file": { + "date": 1577836800, + "display": "Test File Name", + "name": "Test_File_Name.gcode", + "origin": "local", + "path": "Folder1/Folder2/Test_File_Name.gcode", + "size": 123456789, + }, + "lastPrintTime": 12345.678, + "user": "testUser", + }, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Printing", + } diff --git a/tests/components/octoprint/test_servics.py b/tests/components/octoprint/test_services.py similarity index 100% rename from tests/components/octoprint/test_servics.py rename to tests/components/octoprint/test_services.py From ee4325a927426f8208210502f88c09c40c356819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 15 Jul 2025 12:40:48 +0200 Subject: [PATCH 0240/1118] Replace deprecated battery property on Miele vacuum with sensor (#148765) --- homeassistant/components/miele/sensor.py | 10 + homeassistant/components/miele/vacuum.py | 6 - .../miele/snapshots/test_sensor.ambr | 375 ++++++++++++++++++ .../miele/snapshots/test_vacuum.ambr | 12 +- tests/components/miele/test_sensor.py | 15 + 5 files changed, 404 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index ff72b791735..a0daf462c7b 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -539,6 +539,16 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( options=sorted(StateDryingStep.keys()), ), ), + MieleSensorDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleSensorDescription( + key="state_battery", + value_fn=lambda value: value.state_battery_level, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + ), + ), ) diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index 29a89e39bdb..999ceac5cce 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -87,7 +87,6 @@ class MieleVacuumStateCode(MieleEnum): SUPPORTED_FEATURES = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -174,11 +173,6 @@ class MieleVacuum(MieleEntity, StateVacuumEntity): MieleVacuumStateCode(self.device.state_program_phase).value ) - @property - def battery_level(self) -> int | None: - """Return the battery level.""" - return self.device.state_battery_level - @property def fan_speed(self) -> str | None: """Return the fan speed.""" diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index dfc12a52c08..e37af02bf26 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -2920,3 +2920,378 @@ 'state': '0.0', }) # --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:robot-vacuum', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Vacuum_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner', + 'icon': 'mdi:robot-vacuum', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-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.robot_vacuum_cleaner_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Vacuum_1-state_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum cleaner Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_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.robot_vacuum_cleaner_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Vacuum_1-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Vacuum_1-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program', + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Vacuum_1-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_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.robot_vacuum_cleaner_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Vacuum_1-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr index 9f96db7b05a..3b808ad9cd2 100644 --- a/tests/components/miele/snapshots/test_vacuum.ambr +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -34,7 +34,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -43,8 +43,6 @@ # name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -52,7 +50,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', @@ -97,7 +95,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -106,8 +104,6 @@ # name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -115,7 +111,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 47e101c6636..3f66f36f556 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -54,3 +54,18 @@ async def test_hob_sensor_states( """Test sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_vacuum_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot vacuum cleaner sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 7d06aec8dabac85f999aa3f51b5d922e665054da Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Tue, 15 Jul 2025 12:50:28 +0200 Subject: [PATCH 0241/1118] Discovery of Miele temperature sensors (#144585) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/miele/entity.py | 7 +- homeassistant/components/miele/sensor.py | 204 +- .../components/miele/fixtures/4_actions.json | 15 + .../components/miele/fixtures/4_devices.json | 124 + .../miele/fixtures/fridge_freezer.json | 109 + tests/components/miele/fixtures/oven.json | 142 ++ .../miele/snapshots/test_binary_sensor.ambr | 582 +++++ .../miele/snapshots/test_button.ambr | 192 ++ .../miele/snapshots/test_diagnostics.ambr | 168 ++ .../miele/snapshots/test_light.ambr | 114 + .../miele/snapshots/test_sensor.ambr | 2145 +++++++++++++++++ .../miele/snapshots/test_switch.ambr | 96 + tests/components/miele/test_init.py | 8 +- tests/components/miele/test_sensor.py | 189 +- 14 files changed, 4015 insertions(+), 80 deletions(-) create mode 100644 tests/components/miele/fixtures/fridge_freezer.json create mode 100644 tests/components/miele/fixtures/oven.json diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index f9ed4f0bf48..4c6e61f6ea5 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -16,6 +16,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): _attr_has_entity_name = True + @staticmethod + def get_unique_id(device_id: str, description: EntityDescription) -> str: + """Generate a unique ID for the entity.""" + return f"{device_id}-{description.key}" + def __init__( self, coordinator: MieleDataUpdateCoordinator, @@ -26,7 +31,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): super().__init__(coordinator) self._device_id = device_id self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" + self._attr_unique_id = MieleEntity.get_unique_id(device_id, description) device = self.device appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a0daf462c7b..216b91ca68e 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Final, cast -from pymiele import MieleDevice +from pymiele import MieleDevice, MieleTemperature from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,10 +25,13 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + DISABLED_TEMP_ENTITIES, + DOMAIN, STATE_PROGRAM_ID, STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, @@ -45,8 +48,6 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -DISABLED_TEMPERATURE = -32768 - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -75,12 +76,25 @@ def _convert_duration(value_list: list[int]) -> int | None: return value_list[0] * 60 + value_list[1] if value_list else None +def _convert_temperature( + value_list: list[MieleTemperature], index: int +) -> float | None: + """Convert temperature object to readable value.""" + if index >= len(value_list): + return None + raw_value = cast(int, value_list[index].temperature) / 100.0 + if raw_value in DISABLED_TEMP_ENTITIES: + return None + return raw_value + + @dataclass(frozen=True, kw_only=True) class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] - zone: int = 1 + zone: int | None = None + unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None @dataclass @@ -404,32 +418,20 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), description=MieleSensorDescription( key="state_temperature_1", + zone=1, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) - / 100.0, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 0), ), ), MieleSensorDefinition( types=( - MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, - MieleAppliance.OVEN, - MieleAppliance.OVEN_MICROWAVE, - MieleAppliance.DISH_WARMER, - MieleAppliance.STEAM_OVEN, - MieleAppliance.MICROWAVE, - MieleAppliance.FRIDGE, - MieleAppliance.FREEZER, MieleAppliance.FRIDGE_FREEZER, - MieleAppliance.STEAM_OVEN_COMBI, MieleAppliance.WINE_CABINET, MieleAppliance.WINE_CONDITIONING_UNIT, MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, - MieleAppliance.STEAM_OVEN_MICRO, - MieleAppliance.DIALOG_OVEN, MieleAppliance.WINE_CABINET_FREEZER, - MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_temperature_2", @@ -438,7 +440,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_key="temperature_zone_2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + value_fn=lambda value: _convert_temperature(value.state_temperatures, 1), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSensorDescription( + key="state_temperature_3", + zone=3, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_3", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 2), ), ), MieleSensorDefinition( @@ -454,11 +473,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast( - int, value.state_core_target_temperature[0].temperature - ) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_target_temperature, 0 ), ), ), @@ -479,9 +495,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_target_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_target_temperature, 0 ), ), ), @@ -497,9 +512,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_core_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_temperature, 0 ), ), ), @@ -518,6 +532,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.ENUM, options=sorted(PlatePowerStep.keys()), value_fn=lambda value: None, + unique_id_fn=lambda device_id, + description: f"{device_id}-{description.key}-{description.zone}", ), ) for i in range(1, 7) @@ -559,10 +575,52 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data - added_devices: set[str] = set() + added_devices: set[str] = set() # device_id + added_entities: set[str] = set() # unique_id - def _async_add_new_devices() -> None: - nonlocal added_devices + def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: + """Get the entity class for the sensor.""" + return { + "state_status": MieleStatusSensor, + "state_program_id": MieleProgramIdSensor, + "state_program_phase": MielePhaseSensor, + "state_plate_step": MielePlateSensor, + }.get(definition.description.key, MieleSensor) + + def _is_entity_registered(unique_id: str) -> bool: + """Check if the entity is already registered.""" + entity_registry = er.async_get(hass) + return any( + entry.platform == DOMAIN and entry.unique_id == unique_id + for entry in entity_registry.entities.values() + ) + + def _is_sensor_enabled( + definition: MieleSensorDefinition, + device: MieleDevice, + unique_id: str, + ) -> bool: + """Check if the sensor is enabled.""" + if ( + definition.description.device_class == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) is None + and definition.description.zone != 1 + ): + # all appliances supporting temperature have at least zone 1, for other zones + # don't create entity if API signals that datapoint is disabled, unless the sensor + # already appeared in the past (= it provided a valid value) + return _is_entity_registered(unique_id) + if ( + definition.description.key == "state_plate_step" + and definition.description.zone is not None + and definition.description.zone > _get_plate_count(device.tech_type) + ): + # don't create plate entity if not expected by the appliance tech type + return False + return True + + def _async_add_devices() -> None: + nonlocal added_devices, added_entities entities: list = [] entity_class: type[MieleSensor] new_devices_set, current_devices = coordinator.async_add_devices(added_devices) @@ -570,40 +628,35 @@ async def async_setup_entry( for device_id, device in coordinator.data.devices.items(): for definition in SENSOR_TYPES: - if ( - device_id in new_devices_set - and device.device_type in definition.types - ): - match definition.description.key: - case "state_status": - entity_class = MieleStatusSensor - case "state_program_id": - entity_class = MieleProgramIdSensor - case "state_program_phase": - entity_class = MielePhaseSensor - case "state_plate_step": - entity_class = MielePlateSensor - case _: - entity_class = MieleSensor - if ( - definition.description.device_class - == SensorDeviceClass.TEMPERATURE - and definition.description.value_fn(device) - == DISABLED_TEMPERATURE / 100 - ) or ( - definition.description.key == "state_plate_step" - and definition.description.zone - > _get_plate_count(device.tech_type) - ): - # Don't create entity if API signals that datapoint is disabled - continue - entities.append( - entity_class(coordinator, device_id, definition.description) + # device is not supported, skip + if device.device_type not in definition.types: + continue + + entity_class = _get_entity_class(definition) + unique_id = ( + definition.description.unique_id_fn( + device_id, definition.description ) + if definition.description.unique_id_fn is not None + else MieleEntity.get_unique_id(device_id, definition.description) + ) + + # entity was already added, skip + if device_id not in new_devices_set and unique_id in added_entities: + continue + + # sensors is not enabled, skip + if not _is_sensor_enabled(definition, device, unique_id): + continue + + added_entities.add(unique_id) + entities.append( + entity_class(coordinator, device_id, definition.description) + ) async_add_entities(entities) - config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) - _async_add_new_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices)) + _async_add_devices() APPLIANCE_ICONS = { @@ -641,6 +694,17 @@ class MieleSensor(MieleEntity, SensorEntity): entity_description: MieleSensorDescription + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + if description.unique_id_fn is not None: + self._attr_unique_id = description.unique_id_fn(device_id, description) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" @@ -652,16 +716,6 @@ class MielePlateSensor(MieleSensor): entity_description: MieleSensorDescription - def __init__( - self, - coordinator: MieleDataUpdateCoordinator, - device_id: str, - description: MieleSensorDescription, - ) -> None: - """Initialize the plate sensor.""" - super().__init__(coordinator, device_id, description) - self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" - @property def native_value(self) -> StateType: """Return the state of the plate sensor.""" @@ -672,7 +726,7 @@ class MielePlateSensor(MieleSensor): cast( int, self.device.state_plate_step[ - self.entity_description.zone - 1 + cast(int, self.entity_description.zone) - 1 ].value_raw, ) ).name diff --git a/tests/components/miele/fixtures/4_actions.json b/tests/components/miele/fixtures/4_actions.json index 6a89fb4604a..903a075df3c 100644 --- a/tests/components/miele/fixtures/4_actions.json +++ b/tests/components/miele/fixtures/4_actions.json @@ -82,5 +82,20 @@ "colors": [], "modes": [], "runOnTime": [] + }, + "DummyAppliance_12": { + "processAction": [], + "light": [2], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": false, + "powerOff": true, + "colors": [], + "modes": [], + "runOnTime": [] } } diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json index b63c60ff4d3..7d6ee9a7173 100644 --- a/tests/components/miele/fixtures/4_devices.json +++ b/tests/components/miele/fixtures/4_devices.json @@ -466,5 +466,129 @@ "ecoFeedback": null, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json new file mode 100644 index 00000000000..5d091b9c74e --- /dev/null +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -0,0 +1,109 @@ +{ + "DummyAppliance_Fridge_Freezer": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 21, + "value_localized": "Fridge freezer" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KFN 7734 C", + "matNumber": "12336150", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037LHBM", + "releaseVersion": "32.33" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/oven.json b/tests/components/miele/fixtures/oven.json new file mode 100644 index 00000000000..dbf14d4546c --- /dev/null +++ b/tests/components/miele/fixtures/oven.json @@ -0,0 +1,142 @@ +{ + "DummyOven": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [ + "6166", + "25211", + "25210", + "4860", + "25245", + "6153", + "6050", + "25300", + "25307", + "25247", + "20570", + "25223", + "5640", + "20366", + "20462" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index f102c925c98..9a3de2ddd49 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -532,6 +532,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-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.oven_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-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.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-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.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-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.oven_remote_control', + '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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-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.oven_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1647,6 +1938,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-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.oven_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-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.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-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.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-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.oven_remote_control', + '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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-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.oven_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states_api_push[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr index 6e6f3cbb72d..e4eb80587c9 100644 --- a/tests/components/miele/snapshots/test_button.ambr +++ b/tests/components/miele/snapshots/test_button.ambr @@ -47,6 +47,102 @@ 'state': 'unknown', }) # --- +# name: test_button_states[platforms0][button.oven_start-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.oven_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-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.oven_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_states[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -239,6 +335,102 @@ 'state': 'unavailable', }) # --- +# name: test_button_states_api_push[platforms0][button.oven_start-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.oven_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-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.oven_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_button_states_api_push[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 8fa40755888..54f6083a74c 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -144,6 +144,39 @@ 'ventilationStep': list([ ]), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), }), 'devices': dict({ '**REDACTED_019aa577ad1c330d': dict({ @@ -661,6 +694,141 @@ }), }), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '16', + 'fabNumber': '**REDACTED**', + 'matNumber': '11120960', + 'swids': list([ + ]), + 'techType': 'H7660BP', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Oven', + 'value_raw': 12, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Defrost', + 'value_raw': 356, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 22.0, + 'value_raw': 2200, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': 'Heating-up phase', + 'value_raw': 3073, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 1, + }), + 'remainingTime': list([ + 0, + 5, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': True, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 25.0, + 'value_raw': 2500, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 19.54, + 'value_raw': 1954, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), }), 'missing_code_warnings': list([ 'None', diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr index 8c4a4f4bff9..243536fc997 100644 --- a/tests/components/miele/snapshots/test_light.ambr +++ b/tests/components/miele/snapshots/test_light.ambr @@ -113,6 +113,63 @@ 'state': 'on', }) # --- +# name: test_light_states[platforms0][light.oven_light-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.oven_light', + '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': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_light_states_api_push[platforms0][light.hood_ambient_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,3 +284,60 @@ 'state': 'on', }) # --- +# name: test_light_states_api_push[platforms0][light.oven_light-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.oven_light', + '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': 'Light', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index e37af02bf26..915eda4d361 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,207 @@ # serializer version: 1 +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Fridge freezer', + 'icon': 'mdi:fridge-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_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.fridge_freezer_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-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.fridge_freezer_temperature_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature zone 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_zone_2', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature zone 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -808,6 +1011,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_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.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_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.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_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.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-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.oven_start_in', + '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': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_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.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_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.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1640,6 +2758,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_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.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1983,6 +3157,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_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.oven_core_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_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.oven_elapsed_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_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.oven_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-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.oven_start_in', + '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': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_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.oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_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.oven_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2815,6 +4904,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_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.washing_machine_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr index c8ca88c5b59..769b08271a5 100644 --- a/tests/components/miele/snapshots/test_switch.ambr +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -95,6 +95,54 @@ 'state': 'off', }) # --- +# name: test_switch_states[platforms0][switch.oven_power-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.oven_power', + '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': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -287,6 +335,54 @@ 'state': 'off', }) # --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-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.oven_power', + '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': 'Power', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_states_api_push[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index dd3f3b95d02..cdf1a39b421 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -109,7 +109,7 @@ async def test_devices_multiple_created_count( """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) - assert len(device_registry.devices) == 4 + assert len(device_registry.devices) == 5 async def test_device_info( @@ -200,11 +200,13 @@ async def test_setup_all_platforms( ) freezer.tick(timedelta(seconds=130)) + prev_devices = len(device_registry.devices) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(device_registry.devices) == 6 + assert len(device_registry.devices) == prev_devices + 2 # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" - assert hass.states.get("sensor.oven_temperature").state == "175.0" + assert hass.states.get("sensor.oven_temperature_2").state == "175.0" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 3f66f36f556..f35404a665b 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -1,15 +1,24 @@ """Tests for miele sensor module.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pymiele import MieleDevices import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.miele.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @@ -56,6 +65,184 @@ async def test_hob_sensor_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fridge_freezer_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_temperatures_scenario( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + mock_config_entry: MockConfigEntry, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Parametrized test for verifying temperature sensors for oven devices.""" + + # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 0) + + # Simulate temperature settings, no probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2150 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.5 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.5", 1) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 1) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 1) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 1) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 2) + + # Simulate temperature settings with probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2183 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.83 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.83", 3) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 3) + check_sensor_state(hass, "sensor.oven_core_temperature", "22.0", 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "30.0", 3) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "unknown", 4) + + +def check_sensor_state( + hass: HomeAssistant, + sensor_entity: str, + expected: str, + step: int, +): + """Check the state of sensor matches the expected state.""" + + state = hass.states.get(sensor_entity) + + if expected is None: + assert state is None, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected nothing" + ) + else: + assert state is not None, f"Missing entity: {sensor_entity}" + assert state.state == expected, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected {expected}" + ) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_temperature_sensor_registry_lookup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that core temperature sensor is provided by the integration after looking up in entity registry.""" + + # Initial state, the oven is showing core temperature (probe) + freezer.tick(timedelta(seconds=130)) + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "sensor.oven_core_temperature" + + assert hass.states.get(entity_id) is not None + assert hass.states.get(entity_id).state == "22.0" + + # reload device when turned off, reporting the invalid value + mock_miele_client.get_devices.return_value = await async_load_json_object_fixture( + hass, "oven.json", DOMAIN + ) + + # unload config entry and reload to make sure that the entity is still provided + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unknown" + + @pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 0acfb81d500ee049ab11b8bdd0d77ba84d78ef35 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 15 Jul 2025 19:53:29 +0800 Subject: [PATCH 0242/1118] Clean up YoLink entities on startup (#148718) --- homeassistant/components/yolink/__init__.py | 14 ++++ tests/components/yolink/conftest.py | 77 +++++++++++++++++++++ tests/components/yolink/test_init.py | 38 ++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/components/yolink/conftest.py create mode 100644 tests/components/yolink/test_init.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7132fd6a414..96db2ab555a 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -165,6 +165,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( yolink_home, device_coordinators ) + + # Clean up yolink devices which are not associated to the account anymore. + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + for identifier in device_entry.identifiers: + if ( + identifier[0] == DOMAIN + and device_coordinators.get(identifier[1]) is None + ): + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_yolink_unload(event) -> None: diff --git a/tests/components/yolink/conftest.py b/tests/components/yolink/conftest.py new file mode 100644 index 00000000000..2090cd57f2f --- /dev/null +++ b/tests/components/yolink/conftest.py @@ -0,0 +1,77 @@ +"""Provide common fixtures for the YoLink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from yolink.home_manager import YoLinkHome + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yolink.api import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +DOMAIN = "yolink" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="mock_auth_manager") +def mock_auth_manager() -> Generator[MagicMock]: + """Mock the authentication manager.""" + with patch( + "homeassistant.components.yolink.api.ConfigEntryAuth", autospec=True + ) as mock_auth: + mock_auth.return_value = MagicMock(spec=ConfigEntryAuth) + yield mock_auth + + +@pytest.fixture(name="mock_yolink_home") +def mock_yolink_home() -> Generator[AsyncMock]: + """Mock YoLink home instance.""" + with patch( + "homeassistant.components.yolink.YoLinkHome", autospec=True + ) as mock_home: + mock_home.return_value = AsyncMock(spec=YoLinkHome) + yield mock_home + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry for YoLink.""" + config_entry = MockConfigEntry( + unique_id=DOMAIN, + domain=DOMAIN, + title="yolink", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "create", + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/yolink/test_init.py b/tests/components/yolink/test_init.py new file mode 100644 index 00000000000..11d0528dcce --- /dev/null +++ b/tests/components/yolink/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the yolink integration.""" + +import pytest + +from homeassistant.components.yolink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials", "mock_auth_manager", "mock_yolink_home") +async def test_device_remove_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can only remove a device that no longer exists.""" + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "stale_device_id")}, + ) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + device_entry = device_entries[0] + assert device_entry.identifiers == {(DOMAIN, "stale_device_id")} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 0 From cd94685b7d41afcd993bff39810864e1e7ded91a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:55:13 +0800 Subject: [PATCH 0243/1118] Add Fan platform to Switchbot cloud (#148304) Co-authored-by: Joost Lekkerkerker --- .../components/switchbot_cloud/__init__.py | 13 +- .../components/switchbot_cloud/fan.py | 120 +++++++++++ .../components/switchbot_cloud/sensor.py | 1 + tests/components/switchbot_cloud/test_fan.py | 187 ++++++++++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot_cloud/fan.py create mode 100644 tests/components/switchbot_cloud/test_fan.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index b87a569abda..482c5c4a9e6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -51,6 +52,7 @@ class SwitchbotDevices: sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -96,7 +98,6 @@ async def make_switchbot_devices( for device in devices ] ) - return devices_data @@ -177,6 +178,16 @@ async def make_device_data( else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Battery Circulator Fan", + "Circulator Fan", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py new file mode 100644 index 00000000000..d7cf82520ec --- /dev/null +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -0,0 +1,120 @@ +"""Support for the Switchbot Battery Circulator fan.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BatteryCirculatorFanCommands, + BatteryCirculatorFanMode, + CommonCommands, +) + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudFan(data.api, device, coordinator) + for device, coordinator in data.devices.fans + ) + + +class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): + """Representation of a SwitchBot Battery Circulator Fan.""" + + _attr_name = None + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = list(BatteryCirculatorFanMode) + + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str = self.coordinator.data["power"] + mode: str = self.coordinator.data["mode"] + fan_speed: str = self.coordinator.data["fanSpeed"] + self._attr_is_on = power == "on" + self._attr_preset_mode = mode + self._attr_percentage = int(fan_speed) + self._attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.send_api_command(CommonCommands.ON) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(self.preset_mode), + ) + if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(self.percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(BatteryCirculatorFanMode.DIRECT.value), + ) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=preset_mode, + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index f93df234289..75e994b484e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription( SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), + "Battery Circulator Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py new file mode 100644 index 00000000000..4a9eb527818 --- /dev/null +++ b/tests/components/switchbot_cloud/test_fan.py @@ -0,0 +1,187 @@ +"""Test for the Switchbot Battery Circulator Fan.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: + """Test turning on the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turning off the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_set_percentage( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set percentage.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "5"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5}, + blocking=True, + ) + mock_send_command.assert_called() + + +async def test_set_preset_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set preset mode.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "baby", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"}, + blocking=True, + ) + mock_send_command.assert_called_once() From b89b248b4c7ceaabbfadeca24b05ea39d72bc124 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:18:14 +0200 Subject: [PATCH 0244/1118] Add tuya snapshots for qxj category (#148802) --- tests/components/tuya/__init__.py | 8 + .../qxj_temp_humidity_external_probe.json | 65 +++ .../tuya/fixtures/qxj_weather_station.json | 412 +++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 479 ++++++++++++++++++ 4 files changed, 964 insertions(+) create mode 100644 tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json create mode 100644 tests/components/tuya/fixtures/qxj_weather_station.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 09606c7e116..c8f54fa275d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -75,6 +75,14 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qxj_temp_humidity_external_probe": [ + # https://github.com/home-assistant/core/issues/136472 + Platform.SENSOR, + ], + "qxj_weather_station": [ + # https://github.com/orgs/home-assistant/discussions/318 + Platform.SENSOR, + ], "rqbj_gas_sensor": [ # https://github.com/orgs/home-assistant/discussions/100 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json new file mode 100644 index 00000000000..caccb0b9234 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1708196692712PHOeqy", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff00f6abe0563b284t77p", + "name": "Frysen", + "category": "qxj", + "product_id": "is2indt9nlth6esa", + "product_name": "T & H Sensor with external probe", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-27T15:19:27+00:00", + "create_time": "2025-01-27T15:19:27+00:00", + "update_time": "2025-01-27T15:19:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 222, + "humidity_value": 38, + "battery_state": "high", + "temp_current_external": -130 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_weather_station.json b/tests/components/tuya/fixtures/qxj_weather_station.json new file mode 100644 index 00000000000..c52086213fd --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_weather_station.json @@ -0,0 +1,412 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1751921699759JsVujI", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf84c743a84eb2c8abeurz", + "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "category": "qxj", + "product_id": "fsea1lat3vuktbt6", + "product_name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T17:43:41+00:00", + "create_time": "2025-07-07T17:43:41+00:00", + "update_time": "2025-07-07T17:43:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + } + }, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + }, + "fault_type": { + "type": "Enum", + "value": { + "range": [ + "normal", + "ch1_offline", + "ch2_offline", + "ch3_offline", + "offline" + ] + } + }, + "battery_status": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_1": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_2": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_3": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 3000, + "max": 12000, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "wind_direct": { + "type": "Enum", + "value": { + "range": [ + "north", + "north_north_east", + "north_east", + "east_north_east", + "east", + "east_south_east", + "south_east", + "south_south_east", + "south", + "south_south_west", + "south_west", + "west_south_west", + "west", + "west_north_west", + "north_west", + "north_north_west" + ] + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 999999, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 180, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238000, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "com_index": { + "type": "Enum", + "value": { + "range": ["moist", "dry", "comfortable"] + } + } + }, + "status": { + "temp_current": 240, + "humidity_value": 52, + "battery_state": "high", + "temp_unit_convert": "c", + "windspeed_unit_convert": "m_s", + "pressure_unit_convert": "hpa", + "rain_unit_convert": "mm", + "bright_unit_convert": "lux", + "fault_type": "normal", + "battery_status": "low", + "battery_state_1": "high", + "battery_state_2": "high", + "battery_state_3": "low", + "temp_current_external": -400, + "humidity_outdoor": 0, + "temp_current_external_1": 193, + "humidity_outdoor_1": 99, + "temp_current_external_2": 252, + "humidity_outdoor_2": 0, + "temp_current_external_3": -400, + "humidity_outdoor_3": 0, + "atmospheric_pressture": 10040, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "wind_direct": "none", + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 0, + "dew_point_temp": -400, + "feellike_temp": -650, + "heat_index": 260, + "windchill_index": -650, + "com_index": "none" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f63c75567ef..8cf51062a73 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1265,6 +1265,485 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frysen_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_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.frysen_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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.br_7_in_1_wlan_wetterstation_anthrazit_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-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.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- # name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c0585611623798a82e6542a4540cbcfbe7494cfe Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:53:01 -0400 Subject: [PATCH 0245/1118] Add initalize for abstract template entities (#147504) --- .../template/alarm_control_panel.py | 22 ++--------- .../components/template/binary_sensor.py | 16 ++------ homeassistant/components/template/button.py | 10 ++++- homeassistant/components/template/cover.py | 11 ++---- homeassistant/components/template/entity.py | 22 ++++++++++- homeassistant/components/template/fan.py | 11 ++---- homeassistant/components/template/image.py | 10 ++++- homeassistant/components/template/light.py | 11 ++---- homeassistant/components/template/lock.py | 5 ++- homeassistant/components/template/number.py | 16 ++++---- homeassistant/components/template/select.py | 11 ++---- homeassistant/components/template/sensor.py | 16 ++------ homeassistant/components/template/switch.py | 23 +++--------- .../components/template/template_entity.py | 37 ++++++------------- .../components/template/trigger_entity.py | 2 +- homeassistant/components/template/vacuum.py | 11 ++---- homeassistant/components/template/weather.py | 9 ++--- tests/components/template/test_entity.py | 2 +- tests/components/template/test_sensor.py | 4 +- .../template/test_template_entity.py | 2 +- 20 files changed, 106 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a308d55e443..97896e08a68 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -32,8 +32,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -42,7 +40,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -213,6 +211,8 @@ class AbstractTemplateAlarmControlPanel( ): """Representation of a templated Alarm Control Panel features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -363,12 +363,8 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -379,11 +375,6 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -434,11 +425,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 6d41a5804b6..caac43712a7 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -39,8 +39,6 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -51,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -161,6 +159,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -169,11 +168,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) unique_id: str | None, ) -> None: """Initialize the Template binary sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] @@ -182,10 +177,6 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -258,6 +249,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index c52e2dae5a0..397fc5f4174 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.button import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BUTTON_DOMAIN, + ENTITY_ID_FORMAT, ButtonEntity, ) from homeassistant.config_entries import ConfigEntry @@ -84,6 +86,7 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -92,8 +95,11 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): unique_id: str | None, ) -> None: """Initialize the button.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + + if TYPE_CHECKING: + assert self._attr_name is not None + # 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) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 9d6391d80c9..bceac7811f4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -32,12 +32,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -162,6 +161,8 @@ async def async_setup_platform( class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -397,12 +398,8 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateCover.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 3617d9acdee..a97a5ac6571 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -3,21 +3,39 @@ from collections.abc import Sequence from typing import Any +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_OBJECT_ID class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" - def __init__(self, hass: HomeAssistant) -> None: + _entity_id_format: str + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self.hass: + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass + ) + + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 95086375f4b..34faba353d0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -154,6 +153,8 @@ async def async_setup_platform( class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -436,12 +437,8 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateFan.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5f7f06faf4f..ed7093cfcdb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -7,7 +7,11 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ENTITY_ID_FORMAT, + ImageEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -91,6 +95,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): _attr_should_poll = False _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -99,7 +104,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): unique_id: str | None, ) -> None: """Initialize the image.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,6 +140,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity): """Image entity based on trigger data.""" _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT domain = IMAGE_DOMAIN extra_template_keys = (CONF_URL,) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 438c295ecd5..fb97d95db3d 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -43,13 +43,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -215,6 +214,8 @@ async def async_setup_platform( class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__( # pylint: disable=super-init-not-called @@ -893,12 +894,8 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLight.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 20bc098d130..581a037c3d7 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, + ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -104,6 +105,8 @@ async def async_setup_platform( class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -283,7 +286,7 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index fa1e2790a9d..e0b8e7594ce 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.components.number import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN as NUMBER_DOMAIN, + ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -115,6 +115,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -123,8 +124,10 @@ class StateNumberEntity(TemplateEntity, NumberEntity): unique_id: str | None, ) -> None: """Initialize the number.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + if TYPE_CHECKING: + assert self._attr_name is not None + self._value_template = config[CONF_STATE] self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) @@ -136,10 +139,6 @@ class StateNumberEntity(TemplateEntity, NumberEntity): self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _async_setup_templates(self) -> None: @@ -188,6 +187,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): class TriggerNumberEntity(TriggerEntity, NumberEntity): """Number entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = NUMBER_DOMAIN extra_template_keys = ( CONF_STATE, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 55b5c7375f8..d5abf7033a9 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -11,13 +11,13 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + ENTITY_ID_FORMAT, SelectEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -93,6 +93,8 @@ async def async_setup_entry( class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -132,7 +134,7 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): unique_id: str | None, ) -> None: """Initialize the select.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateSelect.__init__(self, config) name = self._attr_name @@ -142,11 +144,6 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - @callback def _async_setup_templates(self) -> None: """Set up templates.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 11fe279fdfb..6fc0588d9c7 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -44,8 +44,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -55,7 +53,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -199,6 +197,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -207,7 +206,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config, unique_id) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) @@ -215,14 +214,6 @@ class StateSensorEntity(TemplateEntity, SensorEntity): self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) @callback def _async_setup_templates(self) -> None: @@ -266,6 +257,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index e2ccb5a8a82..7c1abd6d852 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -30,8 +30,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -40,7 +38,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, @@ -154,6 +152,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -162,11 +161,8 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config, unique_id) + name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -180,10 +176,6 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): self._state: bool | None = False self._attr_assumed_state = self._template is None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _update_state(self, result): @@ -246,6 +238,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): """Switch entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -256,6 +249,7 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): @@ -268,11 +262,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index e404821e651..b5081189cf3 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -240,17 +240,11 @@ class TemplateEntity(AbstractTemplateEntity): def __init__( self, hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, + config: ConfigType, + unique_id: str | None, ) -> None: """Template Entity.""" - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -269,22 +263,13 @@ class TemplateEntity(AbstractTemplateEntity): | None ) = None self._run_variables: ScriptVariables | dict - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - self._run_variables = {} - self._blueprint_inputs = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - self._run_variables = config.get(CONF_VARIABLES, {}) - self._blueprint_inputs = config.get("raw_blueprint_inputs") + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + self._run_variables = config.get(CONF_VARIABLES, {}) + self._blueprint_inputs = config.get("raw_blueprint_inputs") class DummyState(State): """None-state for template entities not yet added to the state machine.""" @@ -302,7 +287,7 @@ class TemplateEntity(AbstractTemplateEntity): variables = {"this": DummyState()} # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name + self._attr_name = None if self._friendly_name_template: with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4565e86843a..66c57eb2aab 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._state_render_error = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index d9c416f4863..143eb837bb5 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -147,6 +146,8 @@ async def async_setup_platform( class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" + _entity_id_format = ENTITY_ID_FORMAT + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called @@ -302,12 +303,8 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateVacuum.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 66ead388d5d..671a2ad0bac 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -35,7 +35,6 @@ from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -153,6 +152,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -161,9 +161,8 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): unique_id: str | None, ) -> None: """Initialize the Template weather.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config, unique_id) - name = self._attr_name self._condition_template = config[CONF_CONDITION_TEMPLATE] self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE] self._humidity_template = config[CONF_HUMIDITY_TEMPLATE] @@ -191,8 +190,6 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self._condition = None self._temperature = None self._humidity = None @@ -486,6 +483,7 @@ class WeatherExtraStoredData(ExtraStoredData): class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = WEATHER_DOMAIN extra_template_keys = ( CONF_CONDITION_TEMPLATE, @@ -501,6 +499,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 67a85839982..4a6940c2813 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None) + entity = abstract_entity.AbstractTemplateEntity(None, {}) with pytest.raises(NotImplementedError): _ = entity.referenced_blueprint diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index e89e98601d6..9aba8511192 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1141,7 +1141,7 @@ async def test_duplicate_templates(hass: HomeAssistant) -> None: "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", @@ -1360,7 +1360,7 @@ async def test_trigger_conditional_entity_invalid_condition( { "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "value_template": "{{ trigger.event.data.beer }}", "entity_picture_template": "{{ '/local/dogs.png' }}", diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index d66fc2710c9..b743f7e2d9f 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None) + entity = template_entity.TemplateEntity(None, {}, "something_unique") with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) From 087a938a7d2194f9c34c3c8bce1a26b1040cfe18 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Wed, 16 Jul 2025 00:32:59 +1000 Subject: [PATCH 0246/1118] Add forecast service to amberelectric (#144848) Co-authored-by: G Johansson --- .../components/amberelectric/__init__.py | 13 +- .../components/amberelectric/const.py | 12 +- .../components/amberelectric/coordinator.py | 25 +- .../components/amberelectric/helpers.py | 25 ++ .../components/amberelectric/icons.json | 5 + .../components/amberelectric/sensor.py | 8 +- .../components/amberelectric/services.py | 121 ++++++++++ .../components/amberelectric/services.yaml | 16 ++ .../components/amberelectric/strings.json | 58 ++++- tests/components/amberelectric/__init__.py | 12 + tests/components/amberelectric/conftest.py | 179 +++++++++++++- tests/components/amberelectric/helpers.py | 150 +++++++++++- .../amberelectric/test_coordinator.py | 28 +-- .../components/amberelectric/test_helpers.py | 17 ++ tests/components/amberelectric/test_sensor.py | 225 +++++++----------- .../components/amberelectric/test_services.py | 202 ++++++++++++++++ 16 files changed, 879 insertions(+), 217 deletions(-) create mode 100644 homeassistant/components/amberelectric/helpers.py create mode 100644 homeassistant/components/amberelectric/services.py create mode 100644 homeassistant/components/amberelectric/services.yaml create mode 100644 tests/components/amberelectric/test_helpers.py create mode 100644 tests/components/amberelectric/test_services.py diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9eab6f42ad3..06641327946 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,11 +2,22 @@ import amberelectric +from homeassistant.components.sensor import ConfigType from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv -from .const import CONF_SITE_ID, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Amber component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 56324628ed6..bdb9aa3186c 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,14 +1,24 @@ """Amber Electric Constants.""" import logging +from typing import Final from homeassistant.const import Platform -DOMAIN = "amberelectric" +DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_CHANNEL_TYPE = "channel_type" + ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + +SERVICE_GET_FORECASTS = "get_forecasts" + +GENERAL_CHANNEL = "general" +CONTROLLED_LOAD_CHANNEL = "controlled_load" +FEED_IN_CHANNEL = "feed_in" diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 1edf64ba0d6..a1efef26aae 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException from homeassistant.config_entries import ConfigEntry @@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +from .helpers import normalize_descriptor type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] @@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: - """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" - if descriptor is None: - return None - if descriptor.value == "spike": - return "spike" - if descriptor.value == "high": - return "high" - if descriptor.value == "neutral": - return "neutral" - if descriptor.value == "low": - return "low" - if descriptor.value == "veryLow": - return "very_low" - if descriptor.value == "extremelyLow": - return "extremely_low" - if descriptor.value == "negative": - return "negative" - return None - - class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" @@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): "grid": {}, } try: - data = self._api.get_current_prices(self.site_id, next=48) + data = self._api.get_current_prices(self.site_id, next=288) intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception diff --git a/homeassistant/components/amberelectric/helpers.py b/homeassistant/components/amberelectric/helpers.py new file mode 100644 index 00000000000..c383c21f276 --- /dev/null +++ b/homeassistant/components/amberelectric/helpers.py @@ -0,0 +1,25 @@ +"""Formatting helpers used to convert things.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +DESCRIPTOR_MAP: dict[str, str] = { + PriceDescriptor.SPIKE: "spike", + PriceDescriptor.HIGH: "high", + PriceDescriptor.NEUTRAL: "neutral", + PriceDescriptor.LOW: "low", + PriceDescriptor.VERYLOW: "very_low", + PriceDescriptor.EXTREMELYLOW: "extremely_low", + PriceDescriptor.NEGATIVE: "negative", +} + + +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: + """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" + if descriptor in DESCRIPTOR_MAP: + return DESCRIPTOR_MAP[descriptor] + return None + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json index 7dd6ae3217c..a2d0a0a5486 100644 --- a/homeassistant/components/amberelectric/icons.json +++ b/homeassistant/components/amberelectric/icons.json @@ -22,5 +22,10 @@ } } } + }, + "services": { + "get_forecasts": { + "service": "mdi:transmission-tower" + } } } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 7276ddb26a5..f7a61bea5a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .helpers import format_cents_to_dollars, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" -def format_cents_to_dollars(cents: float) -> float: - """Return a formatted conversion from cents to dollars.""" - return round(cents / 100, 2) - - def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py new file mode 100644 index 00000000000..074a2f0ac88 --- /dev/null +++ b/homeassistant/components/amberelectric/services.py @@ -0,0 +1,121 @@ +"""Amber Electric Service class.""" + +from amberelectric.models.channel import ChannelType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, + CONTROLLED_LOAD_CHANNEL, + DOMAIN, + FEED_IN_CHANNEL, + GENERAL_CHANNEL, + SERVICE_GET_FORECASTS, +) +from .coordinator import AmberConfigEntry +from .helpers import format_cents_to_dollars, normalize_descriptor + +GET_FORECASTS_SCHEMA = vol.Schema( + { + ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}), + ATTR_CHANNEL_TYPE: vol.In( + [GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL] + ), + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry: + """Get the Amber config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": config_entry_id}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry + + +def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]: + """Return an array of forecasts.""" + results: list[JsonValueType] = [] + + if channel_type not in data["forecasts"]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="channel_not_found", + translation_placeholders={"channel_type": channel_type}, + ) + + intervals = data["forecasts"][channel_type] + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.var_date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEEDIN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + datum["descriptor"] = normalize_descriptor(interval.descriptor) + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + if interval.advanced_price is not None: + multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1 + datum["advanced_price_low"] = multiplier * format_cents_to_dollars( + interval.advanced_price.low + ) + datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars( + interval.advanced_price.predicted + ) + datum["advanced_price_high"] = multiplier * format_cents_to_dollars( + interval.advanced_price.high + ) + + results.append(datum) + + return results + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amber integration.""" + + async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: + channel_type = call.data[ATTR_CHANNEL_TYPE] + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + coordinator = entry.runtime_data + forecasts = get_forecasts(channel_type, coordinator.data) + return {"forecasts": forecasts} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECASTS, + handle_get_forecasts, + GET_FORECASTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/amberelectric/services.yaml b/homeassistant/components/amberelectric/services.yaml new file mode 100644 index 00000000000..89a7027fee0 --- /dev/null +++ b/homeassistant/components/amberelectric/services.yaml @@ -0,0 +1,16 @@ +get_forecasts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: amberelectric + channel_type: + required: true + selector: + select: + options: + - general + - controlled_load + - feed_in + translation_key: channel_type diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 684a5a2a0cc..f9eba4a1f27 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -1,25 +1,61 @@ { "config": { + "error": { + "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", + "no_site": "No site provided", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, "step": { + "site": { + "data": { + "site_id": "Site NMI", + "site_name": "Site name" + }, + "description": "Select the NMI of the site you would like to add" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" - }, - "site": { - "data": { - "site_id": "Site NMI", - "site_name": "Site Name" - }, - "description": "Select the NMI of the site you would like to add" } + } + }, + "services": { + "get_forecasts": { + "name": "Get price forecasts", + "description": "Retrieves price forecasts from Amber Electric for a site.", + "fields": { + "config_entry_id": { + "description": "The config entry of the site to get forecasts for.", + "name": "Config entry" + }, + "channel_type": { + "name": "Channel type", + "description": "The channel to get forecasts for." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Config entry \"{target}\" not found in registry." }, - "error": { - "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", - "no_site": "No site provided", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "not_loaded": { + "message": "{target} is not loaded." + }, + "channel_not_found": { + "message": "There is no {channel_type} channel at this site." + } + }, + "selector": { + "channel_type": { + "options": { + "general": "General", + "controlled_load": "Controlled load", + "feed_in": "Feed-in" + } } } } diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py index 9eae18c65aa..8ee603cee14 100644 --- a/tests/components/amberelectric/__init__.py +++ b/tests/components/amberelectric/__init__.py @@ -1 +1,13 @@ """Tests for the amberelectric integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index ce4073db71b..57f93074883 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,10 +1,59 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from amberelectric.models.interval import Interval import pytest +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_API_TOKEN + +from .helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + FORECASTS, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_CHANNEL_WITH_RANGE, + GENERAL_FORECASTS, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + +MOCK_API_TOKEN = "psk_0000000000000000" + + +def create_amber_config_entry( + site_id: str, entry_id: str, name: str +) -> MockConfigEntry: + """Create an Amber config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_NAME: name, + CONF_SITE_ID: site_id, + }, + entry_id=entry_id, + ) + + +@pytest.fixture +def mock_amber_client() -> Generator[AsyncMock]: + """Mock the Amber API client.""" + with patch( + "homeassistant.components.amberelectric.amberelectric.AmberApi", + autospec=True, + ) as mock_client: + yield mock_client + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.amberelectric.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def general_channel_config_entry(): + """Generate the default Amber config entry.""" + return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home") + + +@pytest.fixture +async def general_channel_and_controlled_load_config_entry(): + """Generate the default Amber config entry for site with controlled load.""" + return create_amber_config_entry( + GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home" + ) + + +@pytest.fixture +async def general_channel_and_feed_in_config_entry(): + """Generate the default Amber config entry for site with feed in.""" + return create_amber_config_entry( + GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home" + ) + + +@pytest.fixture +def general_channel_prices() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL + + +@pytest.fixture +def general_channel_prices_with_range() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL_WITH_RANGE + + +@pytest.fixture +def controlled_load_channel_prices() -> list[Interval]: + """List containing controlled load channel prices.""" + return CONTROLLED_LOAD_CHANNEL + + +@pytest.fixture +def feed_in_channel_prices() -> list[Interval]: + """List containing feed in channel prices.""" + return FEED_IN_CHANNEL + + +@pytest.fixture +def forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return FORECASTS + + +@pytest.fixture +def general_forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return GENERAL_FORECASTS + + +@pytest.fixture +def mock_amber_client_general_channel( + mock_amber_client: AsyncMock, general_channel_prices: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_channel_with_range( + mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices with a range.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices_with_range + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_and_controlled_load( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + controlled_load_channel_prices: list[Interval], +) -> Generator[AsyncMock]: + """Fake general channel and controlled load channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + controlled_load_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_and_feed_in( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + feed_in_channel_prices: list[Interval], +) -> AsyncGenerator[Mock]: + """Set up general channel and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + feed_in_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_forecasts( + mock_amber_client: AsyncMock, forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel, controlled load and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = forecast_prices + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_forecasts( + mock_amber_client: AsyncMock, general_forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel only.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_forecast_prices + return mock_amber_client diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 971f3690a0d..d4f968f01d1 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.advanced_price import AdvancedPrice from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.interval import Interval from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.range import Range from amberelectric.models.spike_status import SpikeStatus from dateutil import parser @@ -15,12 +17,16 @@ from dateutil import parser def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 return Interval( ActualInterval( type="ActualInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I def generate_current_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, + end_time: datetime, + range=False, ) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( CurrentInterval( type="CurrentInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -56,18 +69,28 @@ def generate_current_interval( ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + + return interval + def generate_forecast_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False ) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( ForecastInterval( type="ForecastInterval", duration=30, spot_per_kwh=1.1, - per_kwh=8.8, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -79,12 +102,20 @@ def generate_forecast_interval( estimate=True, ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + if advanced_price: + interval.actual_instance.advanced_price = AdvancedPrice( + low=6.7, predicted=9.0, high=10.2 + ) + return interval GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" +GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG" GENERAL_CHANNEL = [ generate_current_interval( @@ -101,6 +132,21 @@ GENERAL_CHANNEL = [ ), ] +GENERAL_CHANNEL_WITH_RANGE = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True + ), +] + CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") @@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] + +GENERAL_FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] + +FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 6faabc924b4..0e82d81f4e8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.models.channel import Channel, ChannelType from amberelectric.models.interval import Interval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus from amberelectric.models.spike_status import SpikeStatus @@ -17,10 +16,7 @@ from dateutil import parser import pytest from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME -from homeassistant.components.amberelectric.coordinator import ( - AmberUpdateCoordinator, - normalize_descriptor, -) +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -98,18 +94,6 @@ def mock_api_current_price() -> Generator: yield instance -def test_normalize_descriptor() -> None: - """Test normalizing descriptors works correctly.""" - assert normalize_descriptor(None) is None - assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" - assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" - assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" - assert normalize_descriptor(PriceDescriptor.LOW) == "low" - assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(PriceDescriptor.HIGH) == "high" - assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" - - async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" @@ -120,7 +104,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -152,7 +136,7 @@ async def test_fetch_no_general_site( await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) @@ -166,7 +150,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -217,7 +201,7 @@ async def test_fetch_general_and_controlled_load_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_CONTROLLED_SITE_ID, next=48 + GENERAL_AND_CONTROLLED_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -257,7 +241,7 @@ async def test_fetch_general_and_feed_in_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_FEED_IN_SITE_ID, next=48 + GENERAL_AND_FEED_IN_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance diff --git a/tests/components/amberelectric/test_helpers.py b/tests/components/amberelectric/test_helpers.py new file mode 100644 index 00000000000..958c60fd1b3 --- /dev/null +++ b/tests/components/amberelectric/test_helpers.py @@ -0,0 +1,17 @@ +"""Test formatters.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +from homeassistant.components.amberelectric.helpers import normalize_descriptor + + +def test_normalize_descriptor() -> None: + """Test normalizing descriptors works correctly.""" + assert normalize_descriptor(None) is None + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 203b65d6df6..0d979a2021c 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,119 +1,26 @@ """Test the Amber Electric Sensors.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch - -from amberelectric.models.current_interval import CurrentInterval -from amberelectric.models.interval import Interval -from amberelectric.models.range import Range import pytest -from homeassistant.components.amberelectric.const import ( - CONF_SITE_ID, - CONF_SITE_NAME, - DOMAIN, -) -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .helpers import ( - CONTROLLED_LOAD_CHANNEL, - FEED_IN_CHANNEL, - GENERAL_AND_CONTROLLED_SITE_ID, - GENERAL_AND_FEED_IN_SITE_ID, - GENERAL_CHANNEL, - GENERAL_ONLY_SITE_ID, -) - -from tests.common import MockConfigEntry - -MOCK_API_TOKEN = "psk_0000000000000000" +from . import MockConfigEntry, setup_integration -@pytest.fixture -async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_SITE_NAME: "mock_title", - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_ONLY_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_controlled_load( - hass: HomeAssistant, -) -> AsyncGenerator[Mock]: - """Set up general channel and controller load channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel and feed in channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price - assert price.state == "0.08" + assert price.state == "0.09" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.09 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_price_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Price sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 0.08 - assert attributes.get("range_max") == 0.12 + assert attributes.get("range_min") == 0.07 + assert attributes.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Price sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price") assert price - assert price.state == "0.08" + assert price.state == "0.04" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.04 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> assert attributes["attribution"] == "Data provided by Amber Electric" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price") assert price - assert price.state == "-0.08" + assert price.state == "-0.01" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -0.08 + assert attributes["per_kwh"] == -0.01 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: assert attributes["attribution"] == "Data provided by Amber Electric" +@pytest.mark.usefixtures("mock_amber_client_general_channel") async def test_general_forecast_sensor( - hass: HomeAssistant, setup_general: Mock + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry ) -> None: """Test the General Forecast sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price @@ -212,29 +128,33 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[Interval] = GENERAL_CHANNEL - with_range[1].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_forecast_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Forecast sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 0.08 - assert first_forecast.get("range_max") == 0.12 + assert first_forecast.get("range_min") == 0.07 + assert first_forecast.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Load Forecast sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price - assert price.state == "0.09" + assert price.state == "0.04" attributes = price.attributes assert attributes["channel_type"] == "controlledLoad" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["per_kwh"] == 0.04 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Forecast sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price - assert price.state == "-0.09" + assert price.state == "-0.01" attributes = price.attributes assert attributes["channel_type"] == "feedIn" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["per_kwh"] == -0.01 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general") -def test_renewable_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_renewable_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Testing the creation of the Amber renewables sensor.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" -@pytest.mark.usefixtures("setup_general") -def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_descriptor_descriptor_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price Descriptor sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_controlled_load") -def test_general_and_controlled_load_price_descriptor_sensor( +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_descriptor_sensor( hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, ) -> None: """Test the Controlled Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_descriptor_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") assert price diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py new file mode 100644 index 00000000000..7ef895a5d88 --- /dev/null +++ b/tests/components/amberelectric/test_services.py @@ -0,0 +1,202 @@ +"""Test the Amber Service object.""" + +import re + +import pytest +import voluptuous as vol + +from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS +from homeassistant.components.amberelectric.services import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration +from .helpers import ( + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_general_forecasts( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.09 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_controlled_load_forecasts( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.04 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_feed_in_forecasts( + hass: HomeAssistant, + general_channel_and_feed_in_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID, + ATTR_CHANNEL_TYPE: "feed_in", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == -0.01 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_incorrect_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is incorrect.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape( + "value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']" + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "incorrect", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_general_forecasts") +async def test_unavailable_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is not found.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + ServiceValidationError, match="There is no controlled_load channel at this site" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_service_entry_availability( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + general_channel_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(general_channel_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id, + ATTR_CHANNEL_TYPE: "general", + }, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, + match='Config entry "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) From fd10fa1fba8f8ab16a5ad96eb356e7716c978e27 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:49:08 +0200 Subject: [PATCH 0247/1118] Add reauthentication flow to Uptime Kuma (#148772) --- .../components/uptime_kuma/config_flow.py | 47 +++++++++++++ .../components/uptime_kuma/coordinator.py | 4 +- .../components/uptime_kuma/quality_scale.yaml | 2 +- .../components/uptime_kuma/strings.json | 13 +++- .../uptime_kuma/test_config_flow.py | 70 +++++++++++++++++++ tests/components/uptime_kuma/test_init.py | 29 +++++++- 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 9866f08bef3..30f9d7ae9ba 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -38,6 +39,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_API_KEY, default=""): str, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -77,3 +79,48 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma( + session, + entry.data[CONF_URL], + user_input[CONF_API_KEY], + ) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 788d37cfb84..297bd83e7c8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -16,7 +16,7 @@ from pythonkuma import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,7 +59,7 @@ class UptimeKumaDataUpdateCoordinator( try: metrics = await self.api.metrics() except UptimeKumaAuthenticationException as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed_exception", ) from e diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 145cbf58448..c3d88f7e3c8 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 8cd361cccea..0321db1c221 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -13,6 +13,16 @@ "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" } + }, + "reauth_confirm": { + "title": "Re-authenticate with Uptime Kuma: {name}", + "description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -21,7 +31,8 @@ "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%]" } }, "entity": { diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index b70cb9d353c..3c1bf902ce8 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -120,3 +120,73 @@ async def test_form_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py index 57390da60d5..6e2ef43b14d 100644 --- a/tests/components/uptime_kuma/test_init.py +++ b/tests/components/uptime_kuma/test_init.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock import pytest from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -50,3 +51,29 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +async def test_config_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config entry auth error starts reauth flow.""" + + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id From e5fe243a8633785a2a3ac2c2a26556ab8a7cad81 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:03:47 -0400 Subject: [PATCH 0248/1118] Remove device id references from button and image (#148826) --- homeassistant/components/template/button.py | 5 ----- homeassistant/components/template/image.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 397fc5f4174..26d339b7e33 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -105,10 +104,6 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index ed7093cfcdb..57e7c6ffc55 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -107,10 +106,6 @@ class StateImageEntity(TemplateEntity, ImageEntity): TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @property def entity_picture(self) -> str | None: From 35097602d77e4d7813af80353398a5898da2a12f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:04:31 -0400 Subject: [PATCH 0249/1118] Remove unnecessary hass if check in AbstractTemplateEntity (#148828) --- homeassistant/components/template/entity.py | 22 +++++++++---------- tests/components/template/test_entity.py | 8 ++----- .../template/test_template_entity.py | 6 +---- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index a97a5ac6571..481db182713 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,5 +1,6 @@ """Template entity base class.""" +from abc import abstractmethod from collections.abc import Sequence from typing import Any @@ -25,26 +26,25 @@ class AbstractTemplateEntity(Entity): self.hass = hass self._action_scripts: dict[str, Script] = {} - if self.hass: - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - self._entity_id_format, object_id, hass=self.hass - ) - - self._attr_device_info = async_device_info_to_link_from_device_id( - self.hass, - config.get(CONF_DEVICE_ID), + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + self._entity_id_format, object_id, hass=self.hass ) + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property + @abstractmethod def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - raise NotImplementedError @callback + @abstractmethod def _render_script_variables(self) -> dict: """Render configured variables.""" - raise NotImplementedError def add_script( self, diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 4a6940c2813..8e98d8c94a7 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,9 +9,5 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None, {}) - with pytest.raises(NotImplementedError): - _ = entity.referenced_blueprint - - with pytest.raises(NotImplementedError): - entity._render_script_variables() + with pytest.raises(TypeError): + _ = abstract_entity.AbstractTemplateEntity(hass, {}) diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index b743f7e2d9f..7fe3870ae1e 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,12 +9,8 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None, {}, "something_unique") + entity = template_entity.TemplateEntity(hass, {}, "something_unique") - with pytest.raises(ValueError, match="^hass cannot be None"): - entity.add_template_attribute("_hello", template.Template("Hello")) - - entity.hass = object() with pytest.raises(ValueError, match="^template.hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello", None)) From 2c2ac4b6692fb9c8ecac342d416f7cbf2130ed78 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 15 Jul 2025 08:08:19 -0700 Subject: [PATCH 0250/1118] Throw an error from reload_themes if themes are invalid (#148827) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/frontend/__init__.py | 7 +++++ tests/components/frontend/test_init.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9694c299b23..2f2a8e93b1e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted @@ -543,6 +544,12 @@ async def _async_setup_themes( """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) + + try: + THEME_SCHEMA(new_themes) + except vol.Invalid as err: + raise HomeAssistantError(f"Failed to reload themes: {err}") from err + hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f28742cdd0a..a6c35513dc3 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -26,6 +26,7 @@ from homeassistant.components.frontend import ( ) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -408,6 +409,35 @@ async def test_themes_reload_themes( assert msg["result"]["default_theme"] == "default" +@pytest.mark.usefixtures("frontend") +async def test_themes_reload_invalid( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket +) -> None: + """Test frontend.reload_themes service with an invalid theme.""" + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "pink"}}}}, + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + with ( + patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + ), + pytest.raises(HomeAssistantError, match="Failed to reload themes"), + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await themes_ws_client.receive_json() + + assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + assert msg["result"]["default_theme"] == "default" + + async def test_missing_themes(ws_client: MockHAClientWebSocket) -> None: """Test that themes API works when themes are not defined.""" await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) From 5b29d6bbdfbf46306b74696d0bf40c7226a76dc9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 15 Jul 2025 17:25:22 +0200 Subject: [PATCH 0251/1118] Set icon for off state for light domain (#148749) --- homeassistant/components/light/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 6218c733f4c..c0b478e895d 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:lightbulb", + "state": { + "off": "mdi:lightbulb-off" + }, "state_attributes": { "effect": { "default": "mdi:circle-medium", From 8bd51a7fd1470495a87674ec08404f92b36268f3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 17:38:19 +0200 Subject: [PATCH 0252/1118] Use ffmpeg for generic cameras in go2rtc (#148818) --- homeassistant/components/go2rtc/__init__.py | 5 ++++ tests/components/go2rtc/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..8d3e988dd14 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..dcbcb629d11 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -670,3 +670,32 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + camera.set_stream_source("https://my_stream_url.m3u8") + + with patch.object(camera.platform, "platform_name", "generic"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) From 3e0628cec2c365a0276a3fdea03fcba030af58dd Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:58:42 +0200 Subject: [PATCH 0253/1118] Fix entity and device selectors (#148580) --- .../components/ai_task/services.yaml | 7 +-- .../components/assist_satellite/services.yaml | 7 +-- homeassistant/helpers/selector.py | 44 ++++++++++++++++--- tests/helpers/test_selector.py | 21 ++++++++- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 194c0e07bc3..feefa70a30b 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -15,9 +15,10 @@ generate_data: required: false selector: entity: - domain: ai_task - supported_features: - - ai_task.AITaskEntityFeature.GENERATE_DATA + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_DATA structure: advanced: true required: false diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 8433eb6102d..ed292e1626c 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -68,9 +68,10 @@ ask_question: required: true selector: entity: - domain: assist_satellite - supported_features: - - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + filter: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION question: required: false example: "What kind of music would you like to play?" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bc24113251c..83524fac24c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -160,6 +160,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( ) +# Legacy entity selector config schema used directly under entity selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), + # Device class of the entity + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + } +) + + class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" @@ -179,10 +195,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("model"): str, # Model ID of device vol.Optional("model_id"): str, - # Device has to contain entities matching this selector - vol.Optional("entity"): vol.All( - cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] - ), + } +) + + +# Legacy device selector config schema used directly under device selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration linked to it with a config entry + vol.Optional("integration"): str, + # Manufacturer of device + vol.Optional("manufacturer"): str, + # Model of device + vol.Optional("model"): str, } ) @@ -714,9 +742,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema ).extend( { + # Device has to contain entities matching this selector + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, @@ -794,7 +826,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema ).extend( { vol.Optional("exclude_entities"): [str], diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 0e68992d0e4..159f295ab2f 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,7 +88,6 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), - ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -128,6 +127,7 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", } }, ("abc123",), @@ -140,11 +140,13 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", }, { "integration": "matter", "manufacturer": "other-mock-manuf", "model": "other-mock-model", + "model_id": "other-mock-model_id", }, ] }, @@ -158,6 +160,19 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("device", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # model_id should be used under the filter key + {"model_id": "mock-model_id"}, + ], +) +def test_device_selector_schema_error(schema) -> None: + """Test device selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"device": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [ @@ -290,10 +305,12 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + # supported_features should be used under the filter key + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, ], ) def test_entity_selector_schema_error(schema) -> None: - """Test number selector.""" + """Test entity selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"entity": schema}) From 36156d9c544c6e713ae84668247f61e138785bb9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:43:44 +0200 Subject: [PATCH 0254/1118] Update orjson to 3.11.0 (#148840) --- 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 9e21c5830e4..f56c44d494a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index 860b4af379d..6946993e6af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.10.18", + "orjson==3.11.0", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index 118d2bedfa6..896ff44a3c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.10.18 +orjson==3.11.0 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From e89ae021d83a053d9f7ecfe5814ffe28503d48a3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:10:16 +0200 Subject: [PATCH 0255/1118] Clean up validate_supported_features in selector helper (#148843) --- homeassistant/helpers/selector.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 83524fac24c..0fa5403ad2b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc -def _validate_supported_features(supported_features: int | list[str]) -> int: - """Validate a supported feature and resolve an enum string to its value.""" - - if isinstance(supported_features, int): - return supported_features +def _validate_supported_features(supported_features: list[str]) -> int: + """Validate supported features and resolve enum strings to their value.""" feature_mask = 0 From 9caf46c68b2533a86f17810c81ce84fe76339ca3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 15 Jul 2025 20:17:54 +0200 Subject: [PATCH 0256/1118] Bump `imgw_pib` library to version 1.4.0 (#148831) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 3 ++- tests/components/imgw_pib/snapshots/test_diagnostics.ambr | 7 +++++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 631bce3fbc9..a24e5d23907 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.2.0"] + "requirements": ["imgw_pib==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8fe43a3198c..486bd1242f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7e3da48a19..f6f6af12452 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index e0b091e5ff3..ad5ad992688 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import HydrologicalData, SensorData +from imgw_pib import NO_ALERT, Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,6 +25,7 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), + alert=Alert(value=NO_ALERT), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 08f3690136e..1521bc8320a 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -22,6 +22,13 @@ 'version': 1, }), 'hydrological_data': dict({ + 'alert': dict({ + 'level': None, + 'probability': None, + 'valid_from': None, + 'valid_to': None, + 'value': 'no_alert', + }), 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', From d14a0e01911358055a545812abb3ec1d53c36059 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:18:47 +0200 Subject: [PATCH 0257/1118] Bump pythonkuma to v0.3.1 (#148834) --- homeassistant/components/uptime_kuma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6f20d4ae20f..42fac89a976 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "bronze", - "requirements": ["pythonkuma==0.3.0"] + "requirements": ["pythonkuma==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 486bd1242f1..b09aff3f4bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2526,7 +2526,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6f6af12452..081a7d6ed5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2090,7 +2090,7 @@ python-technove==2.0.0 python-telegram-bot[socks]==21.5 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 From 648dce2fa39ca63cbf5d53ec29892f4aac515961 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:19:14 +0200 Subject: [PATCH 0258/1118] Add diagnostics platform to Uptime Kuma (#148835) --- .../components/uptime_kuma/diagnostics.py | 23 +++++++++++ .../components/uptime_kuma/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 41 +++++++++++++++++++ .../uptime_kuma/test_diagnostics.py | 28 +++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/uptime_kuma/diagnostics.py create mode 100644 tests/components/uptime_kuma/snapshots/test_diagnostics.ambr create mode 100644 tests/components/uptime_kuma/test_diagnostics.py diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py new file mode 100644 index 00000000000..48e23adc40d --- /dev/null +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics platform for Uptime Kuma.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry + +TO_REDACT = {"monitor_url", "monitor_hostname"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UptimeKumaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return async_redact_data( + {k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index c3d88f7e3c8..469ecad8d7b 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: is not locally discoverable diff --git a/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97e40e821da --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '1': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Monitor 1', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 1, + 'monitor_type': 'http', + 'monitor_url': '**REDACTED**', + }), + '2': dict({ + 'monitor_cert_days_remaining': 0, + 'monitor_cert_is_valid': 0, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'Monitor 2', + 'monitor_port': None, + 'monitor_response_time': 28, + 'monitor_status': 1, + 'monitor_type': 'port', + 'monitor_url': None, + }), + '3': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Monitor 3', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 0, + 'monitor_type': 'json-query', + 'monitor_url': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/uptime_kuma/test_diagnostics.py b/tests/components/uptime_kuma/test_diagnostics.py new file mode 100644 index 00000000000..92d98d49b75 --- /dev/null +++ b/tests/components/uptime_kuma/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests Uptime Kuma diagnostics platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f5b785acd50269268fa7502afdcf2c7b46299004 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:44:32 +0200 Subject: [PATCH 0259/1118] Update youtubeaio to 2.0.0 (#148814) --- homeassistant/components/youtube/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index a1a71f6712e..56b0f0fdd3a 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==1.1.5"] + "requirements": ["youtubeaio==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b09aff3f4bc..79d34968d39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3172,7 +3172,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 081a7d6ed5e..05c9ff6adf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2619,7 +2619,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 From 381bd489d801e816315d38da0717e89fef4060eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:13:03 +0200 Subject: [PATCH 0260/1118] Do not add template config entry to source device (#148756) --- homeassistant/components/template/entity.py | 9 ++-- tests/components/template/test_init.py | 49 +++++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 481db182713..31c48917a1f 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType from homeassistant.helpers.template import TemplateStateFromEntityId @@ -31,10 +31,9 @@ class AbstractTemplateEntity(Entity): self._entity_id_format, object_id, hass=self.hass ) - self._attr_device_info = async_device_info_to_link_from_device_id( - self.hass, - config.get(CONF_DEVICE_ID), - ) + device_registry = dr.async_get(hass) + if (device_id := config.get(CONF_DEVICE_ID)) is not None: + self.device_entry = device_registry.async_get(device_id) @property @abstractmethod diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index cab940d4c66..0d593da9fba 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -9,7 +9,7 @@ from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -369,6 +369,7 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: async def test_change_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, config_entry_options: dict[str, str], config_user_input: dict[str, str], ) -> None: @@ -379,6 +380,19 @@ async def test_change_device( changed in the integration options. """ + def check_template_entities( + template_entity_id: str, + device_id: str | None = None, + ) -> None: + """Check that the template entity is linked to the correct device.""" + template_entity_ids: list[str] = [] + for template_entity in entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ): + template_entity_ids.append(template_entity.entity_id) + assert template_entity.device_id == device_id + assert template_entity_ids == [template_entity_id] + # Configure devices registry entry_device1 = MockConfigEntry() entry_device1.add_to_hass(hass) @@ -413,9 +427,14 @@ async def test_change_device( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the config entry has been added to the device 1 registry (current) - current_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id in current_device.config_entries + template_entity_id = f"{config_entry_options['template_type']}.my_template" + + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 1 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id1) # Change config options to use device 2 and reload the integration result = await hass.config_entries.options.async_init( @@ -427,13 +446,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 1 registry - previous_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id not in previous_device.config_entries - - # Confirm that the config entry has been added to the device 2 registry (current) - current_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id in current_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 2 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id2) # Change the config options to remove the device and reload the integration result = await hass.config_entries.options.async_init( @@ -445,9 +463,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 2 registry - previous_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id not in previous_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are not linked to any device + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, None) # Confirm that there is no device with the helper config entry assert ( From 3cb579d5857bfd96f16b7d65e2e970b9eacca0d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:13:26 +0200 Subject: [PATCH 0261/1118] Do not add statistics config entry to source device (#148731) --- .../components/statistics/__init__.py | 45 ++++- .../components/statistics/config_flow.py | 20 ++- homeassistant/components/statistics/sensor.py | 12 +- tests/components/statistics/test_init.py | 166 ++++++++++++++++-- 4 files changed, 213 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f800c82f1f9..34799e366d1 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,5 +1,7 @@ """The statistics component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,15 +9,21 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Statistics from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -36,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -52,6 +61,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the statistics config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Statistics config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index fb8c09868d5..d9ff172e0a4 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -161,6 +161,8 @@ OPTIONS_FLOW = { class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Statistics.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -234,15 +236,15 @@ async def ws_start_preview( ) preview_entity = StatisticsSensor( hass, - entity_id, - name, - None, - state_characteristic, - sampling_size, - max_age, - msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), - msg["user_input"].get(CONF_PRECISION), - msg["user_input"].get(CONF_PERCENTILE), + source_entity_id=entity_id, + name=name, + unique_id=None, + state_characteristic=state_characteristic, + samples_max_buffer_size=sampling_size, + samples_max_age=max_age, + samples_keep_last=msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), + precision=msg["user_input"].get(CONF_PRECISION), + percentile=msg["user_input"].get(CONF_PERCENTILE), ) preview_entity.hass = hass diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index a5c5f10ecd0..8129a000b91 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -46,7 +46,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -659,6 +659,7 @@ class StatisticsSensor(SensorEntity): def __init__( self, hass: HomeAssistant, + *, source_entity_id: str, name: str, unique_id: str | None, @@ -673,10 +674,11 @@ class StatisticsSensor(SensorEntity): self._attr_name: str = name self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self.is_binary: bool = ( split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN ) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index c11045a2eb2..2312daa8c52 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components import statistics from homeassistant.components.statistics import DOMAIN from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -85,6 +85,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -173,7 +174,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(statistics_config_entry.entry_id) @@ -188,9 +189,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -201,6 +200,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the statistics config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -217,7 +263,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -234,7 +280,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -261,7 +310,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -276,7 +325,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the entity is no longer linked to the source device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id is None + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -309,7 +362,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert statistics_config_entry.entry_id not in sensor_device_2.config_entries @@ -326,11 +379,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is moved to the other device + # Check that the entity is linked to the other device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert statistics_config_entry.entry_id in sensor_device_2.config_entries + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -355,7 +412,7 @@ async def test_async_handle_source_entity_new_entity_id( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -373,12 +430,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the statistics config entry is updated with the new entity ID assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes statistics config entry from device.""" + + statistics_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=1, + minor_version=1, + ) + statistics_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + assert statistics_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + assert statistics_config_entry.version == 1 + assert statistics_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": "sensor.test", + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 849a25e3ccaadf8fd2fb11e3efda89c385438af5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:19:32 +0200 Subject: [PATCH 0262/1118] Handle changes to source entities in mold_indicator helper (#148823) Co-authored-by: G Johansson --- .../components/mold_indicator/__init__.py | 117 ++- .../components/mold_indicator/config_flow.py | 3 + .../components/mold_indicator/sensor.py | 4 +- tests/components/mold_indicator/test_init.py | 679 +++++++++++++++++- 4 files changed, 798 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index c426b942af5..e252338d4d8 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1,15 +1,93 @@ """Calculates mold growth indication from temperature and humidity.""" +from __future__ import annotations + +from collections.abc import Callable +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device import ( + async_entity_id_to_device_id, + async_remove_stale_devices_links_keep_entity_device, +) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +from .const import CONF_INDOOR_HUMIDITY, CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mold indicator from a config entry.""" + # This can be removed in HA Core 2026.2 + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_INDOOR_HUMIDITY] + ) + + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id}, + ) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidity + # sensor, but not the temperature sensors because the mold_indicator links + # to the humidity sensor's device. + async_handle_source_entity_changes( + hass, + add_helper_config_entry_to_device=False, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_INDOOR_HUMIDITY] + ), + source_entity_id_or_uuid=entry.options[CONF_INDOOR_HUMIDITY], + ) + ) + + for temp_sensor in (CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP): + + def get_temp_sensor_updater( + temp_sensor: str, + ) -> Callable[[Event[er.EventEntityRegistryUpdatedData]], None]: + """Return a function to update the config entry with the new temp sensor.""" + + @callback + def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, temp_sensor: data["entity_id"]}, + ) + + return async_sensor_updated + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[temp_sensor], get_temp_sensor_updater(temp_sensor) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -24,3 +102,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Remove the mold indicator config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_INDOOR_HUMIDITY] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index 5e5512a60bf..d370752fff9 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -101,6 +101,9 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 451cc65fb55..62906ea65ae 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -35,7 +35,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -173,7 +173,7 @@ class MoldIndicator(SensorEntity): self._indoor_hum: float | None = None self._crit_temp: float | None = None if indoor_humidity_sensor: - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, indoor_humidity_sensor, ) diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 5fd6b11c8fe..bfa8ad3a0ef 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -2,12 +2,190 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import mold_indicator +from homeassistant.components.mold_indicator.config_flow import ( + MoldIndicatorConfigFlowHandler, +) +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def indoor_humidity_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_humidity_device( + device_registry: dr.DeviceRegistry, indoor_humidity_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_humidity_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:ED")}, + ) + + +@pytest.fixture +def indoor_humidity_entity_entry( + entity_registry: er.EntityRegistry, + indoor_humidity_config_entry: ConfigEntry, + indoor_humidity_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_humidity", + config_entry=indoor_humidity_config_entry, + device_id=indoor_humidity_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def indoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_temperature_device( + device_registry: dr.DeviceRegistry, indoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def indoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + indoor_temperature_config_entry: ConfigEntry, + indoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_temperature", + config_entry=indoor_temperature_config_entry, + device_id=indoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def outdoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def outdoor_temperature_device( + device_registry: dr.DeviceRegistry, outdoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=outdoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def outdoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + outdoor_temperature_config_entry: ConfigEntry, + outdoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_outdoor_temperature", + config_entry=outdoor_temperature_config_entry, + device_id=outdoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def mold_indicator_config_entry( + hass: HomeAssistant, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a mold_indicator config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=MoldIndicatorConfigFlowHandler.VERSION, + minor_version=MoldIndicatorConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + indoor_humidity_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return indoor_humidity_device.id if request.param == "humidity_device_id" else None + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -15,3 +193,500 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleaning of devices linked to the helper config entry.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "indoor", + "humidity", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.indoor_humidity") is not None + + # Configure the configuration entry for helper + helper_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="Test", + ) + helper_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to config entry + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, 3 devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_before_reload) == 2 + + # Config entry reload + await hass.config_entries.async_reload(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_after_reload) == 0 + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check if the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("sensor.test_unique_indoor_humidity", 1, None, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", 0, "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity from the device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "unload_entry_calls", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", 1, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, []), + ("sensor.test_unique_outdoor_temperature", 0, []), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Move the source entity to another device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + indoor_humidity_entity_entry = entity_registry.async_get( + indoor_humidity_entity_entry.entity_id + ) + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + # Check that the mold_indicator config entry is not in any of the devices + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "config_key"), + [ + ("sensor.test_unique_indoor_humidity", CONF_INDOOR_HUMIDITY), + ("sensor.test_unique_indoor_temperature", CONF_INDOOR_TEMP), + ("sensor.test_unique_outdoor_temperature", CONF_OUTDOOR_TEMP), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the mold_indicator config entry is updated with the new entity ID + assert mold_indicator_config_entry.options[config_key] == "sensor.new_entity_id" + + # Check that the helper config is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + indoor_humidity_device: dr.DeviceEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes mold_indicator config entry from device.""" + + mold_indicator_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=1, + minor_version=1, + ) + mold_indicator_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + indoor_humidity_device.id, + add_config_entry_id=mold_indicator_config_entry.entry_id, + ) + + # Check preconditions + switch_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + assert mold_indicator_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + assert mold_indicator_config_entry.version == 1 + assert mold_indicator_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR From 828f0f8b26d38fe8fdf757d203d4a950ce2c2519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 15 Jul 2025 22:43:40 +0200 Subject: [PATCH 0263/1118] Update aioairzone-cloud to v0.6.14 (#148820) --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 37 ++++++++++++---- .../airzone_cloud/test_binary_sensor.py | 2 +- tests/components/airzone_cloud/test_sensor.py | 10 ++--- tests/components/airzone_cloud/util.py | 42 +++++++++++-------- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e185ed89106..3a494aa361e 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.13"] + "requirements": ["aioairzone-cloud==0.6.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79d34968d39..f716b5a5518 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.13 +aioairzone-cloud==0.6.14 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05c9ff6adf2..c65d4cf545e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.13 +aioairzone-cloud==0.6.14 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4bd7bfaccdd..3d566e6297b 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -210,10 +210,35 @@ 'ws-connected': True, }), }), + 'air-quality': dict({ + 'airqsensor1': dict({ + 'aq-active': False, + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', + 'available': True, + 'double-set-point': False, + 'id': 'airqsensor1', + 'installation': 'installation1', + 'is-connected': True, + 'name': 'CapteurQ', + 'problems': False, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + }), 'groups': dict({ 'group1': dict({ 'action': 1, 'active': True, + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'hot-water': list([ 'dhw1', @@ -332,6 +357,9 @@ 'aidoo1', 'aidoo_pro', ]), + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'groups': list([ 'group1', @@ -377,6 +405,7 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-active': False, 'aq-index': 1, 'aq-pm-1': 3, 'aq-pm-10': 3, @@ -463,6 +492,7 @@ 'action': 1, 'active': True, 'air-demand': True, + 'air-quality-id': 'airqsensor1', 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -528,19 +558,12 @@ 'action': 6, 'active': False, 'air-demand': False, - 'aq-active': False, - 'aq-index': 1, 'aq-mode-conf': 'auto', 'aq-mode-values': list([ 'off', 'on', 'auto', ]), - 'aq-pm-1': 3, - 'aq-pm-10': 3, - 'aq-pm-2.5': 4, - 'aq-present': True, - 'aq-status': 'good', 'available': True, 'double-set-point': False, 'floor-demand': False, diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index bb2d0f78060..d88f66e6b2c 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -45,7 +45,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dormitorio_air_quality_active") - assert state.state == STATE_OFF + assert state is None state = hass.states.get("binary_sensor.dormitorio_battery") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 672e10adedb..330a9efbef1 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -59,19 +59,19 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: # Zones state = hass.states.get("sensor.dormitorio_air_quality_index") - assert state.state == "1" + assert state is None state = hass.states.get("sensor.dormitorio_battery") assert state.state == "54" state = hass.states.get("sensor.dormitorio_pm1") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_pm2_5") - assert state.state == "4" + assert state is None state = hass.states.get("sensor.dormitorio_pm10") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_signal_percentage") assert state.state == "76" @@ -82,7 +82,7 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.dormitorio_humidity") assert state.state == "24" - state = hass.states.get("sensor.dormitorio_air_quality_index") + state = hass.states.get("sensor.salon_air_quality_index") assert state.state == "1" state = hass.states.get("sensor.salon_pm1") diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 52b0ae0bec3..835011f8c8c 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -19,6 +19,7 @@ from aioairzone_cloud.const import ( API_AZ_ACS, API_AZ_AIDOO, API_AZ_AIDOO_PRO, + API_AZ_AIRQSENSOR, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -170,6 +171,17 @@ GET_INSTALLATION_MOCK = { }, API_WS_ID: WS_ID, }, + { + API_CONFIG: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_DEVICE_ID: "airqsensor1", + API_NAME: "CapteurQ", + API_TYPE: API_AZ_AIRQSENSOR, + API_META: {}, + API_WS_ID: WS_ID, + }, ], }, { @@ -394,11 +406,6 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "system1": return { API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -419,14 +426,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_AIR_ACTIVE: True, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, @@ -466,14 +467,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_AIR_ACTIVE: False, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, @@ -504,6 +499,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } + if device.get_id() == "airqsensor1": + return { + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + } return {} From d46e0e132b05ce0abe5a77dcf6dd505e316fbf4a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:46:37 +0200 Subject: [PATCH 0264/1118] Add reconfigure flow to Uptime Kuma (#148833) --- .../components/uptime_kuma/config_flow.py | 104 +++++++++++++----- .../components/uptime_kuma/quality_scale.yaml | 2 +- .../components/uptime_kuma/strings.json | 16 ++- .../uptime_kuma/test_config_flow.py | 90 +++++++++++++++ 4 files changed, 180 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 30f9d7ae9ba..da71084d1bc 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -16,6 +16,7 @@ from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -42,6 +43,29 @@ STEP_USER_DATA_SCHEMA = vol.Schema( STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) +async def validate_connection( + hass: HomeAssistant, + url: URL | str, + verify_ssl: bool, + api_key: str, +) -> dict[str, str]: + """Validate Uptime Kuma connectivity.""" + errors: dict[str, str] = {} + session = async_get_clientsession(hass, verify_ssl) + uptime_kuma = UptimeKuma(session, url, api_key) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Uptime Kuma.""" @@ -54,19 +78,14 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): url = URL(user_input[CONF_URL]) self._async_abort_entries_match({CONF_URL: url.human_repr()}) - session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) - uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) - - try: - await uptime_kuma.metrics() - except UptimeKumaAuthenticationException: - errors["base"] = "invalid_auth" - except UptimeKumaException: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): return self.async_create_entry( title=url.host or "", data={**user_input, CONF_URL: url.human_repr()}, @@ -95,23 +114,14 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): entry = self._get_reauth_entry() if user_input is not None: - session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL]) - uptime_kuma = UptimeKuma( - session, - entry.data[CONF_URL], - user_input[CONF_API_KEY], - ) - - try: - await uptime_kuma.metrics() - except UptimeKumaAuthenticationException: - errors["base"] = "invalid_auth" - except UptimeKumaException: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not ( + errors := await validate_connection( + self.hass, + entry.data[CONF_URL], + entry.data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): return self.async_update_reload_and_abort( entry, data_updates=user_input, @@ -124,3 +134,37 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 469ecad8d7b..876318c8917 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: has no repairs diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 0321db1c221..87dcf6e8cf7 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -23,6 +23,19 @@ "data_description": { "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" } + }, + "reconfigure": { + "title": "Update configuration for Uptime Kuma", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::uptime_kuma::config::step::user::data_description::url%]", + "verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]", + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -32,7 +45,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "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%]" } }, "entity": { diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index 3c1bf902ce8..ab695107b9b 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -190,3 +190,93 @@ async def test_flow_reauth_errors( assert config_entry.data[CONF_API_KEY] == "newapikey" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 From 7f2a32d4ebc4298311b0ea763e03c28b5224f692 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 23:11:55 +0200 Subject: [PATCH 0265/1118] Remove not needed go2rtc stream config (#148836) --- homeassistant/components/go2rtc/__init__.py | 1 - tests/components/go2rtc/test_init.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 8d3e988dd14..aeedb847090 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -328,7 +328,6 @@ class WebRTCProvider(CameraWebRTCProvider): # Connection problems to the camera will be logged by the first stream # Therefore setting it to debug will not hide any important logs f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index dcbcb629d11..0a071f45ef7 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -120,7 +120,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -139,7 +138,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -696,6 +694,5 @@ async def test_generic_workaround( [ "ffmpeg:https://my_stream_url.m3u8", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) From 38e4e18f60dea3e4a5a5c029131abe99e1fe1303 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 01:41:56 +0200 Subject: [PATCH 0266/1118] Bump IMGW-PIB to version 1.4.1 (#148849) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 2 +- .../imgw_pib/snapshots/test_diagnostics.ambr | 14 +++++++------- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index a24e5d23907..e2032b6d51a 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.4.0"] + "requirements": ["imgw_pib==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f716b5a5518..1b8fc7b8801 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.0 +imgw_pib==1.4.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c65d4cf545e..2b4ff300a02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.0 +imgw_pib==1.4.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index ad5ad992688..c3f87288573 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -25,7 +25,7 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), - alert=Alert(value=NO_ALERT), + hydrological_alert=Alert(value=NO_ALERT), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 1521bc8320a..be2afee3da9 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -22,13 +22,6 @@ 'version': 1, }), 'hydrological_data': dict({ - 'alert': dict({ - 'level': None, - 'probability': None, - 'valid_from': None, - 'valid_to': None, - 'value': 'no_alert', - }), 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', @@ -41,6 +34,13 @@ 'unit': None, 'value': None, }), + 'hydrological_alert': dict({ + 'level': None, + 'probability': None, + 'valid_from': None, + 'valid_to': None, + 'value': 'no_alert', + }), 'latitude': None, 'longitude': None, 'river': 'River Name', From 57e4270b7b75f420815dd8e518cc1512725e5340 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 16 Jul 2025 08:06:49 +0200 Subject: [PATCH 0267/1118] Make exceptions translatable in inexogy integration (#148865) --- homeassistant/components/discovergy/__init__.py | 9 +++++++-- homeassistant/components/discovergy/coordinator.py | 11 +++++++++-- .../components/discovergy/quality_scale.yaml | 8 ++++++-- homeassistant/components/discovergy/strings.json | 11 +++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 0a8b7422f84..65687debd3a 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import create_async_httpx_client +from .const import DOMAIN from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -30,10 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - # if no exception is raised everything is fine to go meters = await client.meters() except discovergyError.InvalidLogin as err: - raise ConfigEntryAuthFailed("Invalid email or password") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except Exception as err: raise ConfigEntryNotReady( - "Unexpected error while while getting meters" + translation_domain=DOMAIN, + translation_key="cannot_connect_meters_setup", ) from err # Init coordinators for meters diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index e3f26ad49f8..2c77ab2388e 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] @@ -51,7 +53,12 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - "Auth expired while fetching last reading" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed(f"Error while fetching last reading: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="reading_update_failed", + translation_placeholders={"meter_id": self.meter.meter_id}, + ) from err diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 56af1d97304..a8f140f258c 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -72,12 +72,16 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | The integration does not provide any additional icons. - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: | + No configuration besides credentials. + New credentials will create a new config entry. repair-issues: status: exempt comment: | diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 0058f874a36..911a4a1c4f5 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -23,6 +23,17 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "exceptions": { + "invalid_auth": { + "message": "Authentication failed. Please check your inexogy email and password." + }, + "cannot_connect_meters_setup": { + "message": "Failed to connect and retrieve meters from inexogy during setup. Please ensure the service is reachable and try again." + }, + "reading_update_failed": { + "message": "Error fetching the latest reading for meter {meter_id} from inexogy. The service might be temporarily unavailable or there's a connection issue. Check logs for more details." + } + }, "system_health": { "info": { "api_endpoint_reachable": "inexogy API endpoint reachable" From 549069e22cbb3de107f3b504783a3328f139288f Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:09:24 -0400 Subject: [PATCH 0268/1118] Add guard to prevent exception in Sonos Favorites (#148854) --- homeassistant/components/sonos/favorites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 8824c56a762..c1e1b4f80df 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -72,7 +72,7 @@ class SonosFavorites(SonosHouseholdCoordinator): """Process the event payload in an async lock and update entities.""" event_id = event.variables["favorites_update_id"] container_ids = event.variables["container_update_i_ds"] - if not (match := re.search(r"FV:2,(\d+)", container_ids)): + if not container_ids or not (match := re.search(r"FV:2,(\d+)", container_ids)): return container_id = int(match.group(1)) From ffc2b0a8cf0b7efb8a837b993d6f0b610174a611 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 16:09:54 +1000 Subject: [PATCH 0269/1118] Add mock for listen in Teslemetry tests (#148853) --- tests/components/teslemetry/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 0152543e512..b9b5efae6ec 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -119,8 +119,17 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_add_listener(): +def mock_stream_listen(): """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStream.listen", + ) as mock_stream_listen: + yield mock_stream_listen + + +@pytest.fixture(autouse=True) +def mock_add_listener(): + """Mock Teslemetry Stream add listener method.""" with patch( "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: From 2011e643905233d8e63310d1fd5852252484204c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 16 Jul 2025 08:10:29 +0200 Subject: [PATCH 0270/1118] Different fixes in user-facing strings of `nasweb` (#148830) --- homeassistant/components/nasweb/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 2e1ea55ffcb..73b91768374 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -15,7 +15,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_internal_url": "Make sure Home Assistant has a valid internal URL", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_nasweb_data": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant.", "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." + "message": "Invalid username/password. Most likely the user has changed their password or has been removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" @@ -43,7 +43,7 @@ "entity": { "switch": { "switch_output": { - "name": "Relay Switch {index}" + "name": "Relay switch {index}" } }, "sensor": { @@ -52,8 +52,8 @@ "state": { "undefined": "Undefined", "tamper": "Tamper", - "active": "Active", - "normal": "Normal", + "active": "[%key:common::state::active%]", + "normal": "[%key:common::state::normal%]", "problem": "Problem" } } From 9c933ef01fd3632644c3d1983105af25a3f82d7b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:10:56 +0200 Subject: [PATCH 0271/1118] Add support for HmIPW-DRBL4 in homematicip_cloud (#148844) --- homeassistant/components/homematicip_cloud/cover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f9986e0c526..931b689fb08 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -12,6 +12,7 @@ from homematicip.device import ( FullFlushShutter, GarageDoorModuleTormatic, HoermannDrivesModule, + WiredDinRailBlind4, ) from homematicip.group import ExtendedLinkedShutterGroup @@ -48,7 +49,7 @@ async def async_setup_entry( for device in hap.home.devices: if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, DinRailBlind4): + elif isinstance(device, (DinRailBlind4, WiredDinRailBlind4)): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) From 27ad459ae06633d739e7108bd502c4dcb1f10b59 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:11:55 +0200 Subject: [PATCH 0272/1118] Add tuya snapshots for more humidifiers (cs category) (#148797) --- tests/components/tuya/__init__.py | 14 ++ .../tuya/fixtures/cs_emma_dehumidifier.json | 129 ++++++++++++++++++ .../tuya/fixtures/cs_smart_dry_plus.json | 32 +++++ .../tuya/snapshots/test_binary_sensor.ambr | 98 +++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 104 ++++++++++++++ .../tuya/snapshots/test_humidifier.ambr | 110 +++++++++++++++ .../tuya/snapshots/test_select.ambr | 61 +++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++ .../tuya/snapshots/test_switch.ambr | 98 +++++++++++++ 9 files changed, 699 insertions(+) create mode 100644 tests/components/tuya/fixtures/cs_emma_dehumidifier.json create mode 100644 tests/components/tuya/fixtures/cs_smart_dry_plus.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c8f54fa275d..129930b810f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -31,6 +31,20 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_emma_dehumidifier": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "cs_smart_dry_plus": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.FAN, + Platform.HUMIDIFIER, + ], "cwwsq_cleverio_pf100": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, diff --git a/tests/components/tuya/fixtures/cs_emma_dehumidifier.json b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json new file mode 100644 index 00000000000..8a2fd881262 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json @@ -0,0 +1,129 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifer", + "category": "cs", + "product_id": "ka2wfrdoogpvgzfi", + "product_name": "Emma Dehumidifier - eeese air care", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-11-06T18:25:00+00:00", + "create_time": "2024-11-06T18:25:00+00:00", + "update_time": "2024-11-06T18:25:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "h", + "min": 0, + "max": 24, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L3", "L4", "L2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 25, + "fan_speed_enum": "low", + "anion": false, + "child_lock": false, + "humidity_indoor": 48, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_smart_dry_plus.json b/tests/components/tuya/fixtures/cs_smart_dry_plus.json new file mode 100644 index 00000000000..ff922f506c5 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_smart_dry_plus.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifier ", + "category": "cs", + "product_id": "vmxuxszzjwp5smli", + "product_name": "the Smart Dry Plus\u2122 Connect Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2024-05-28T01:57:58+00:00", + "create_time": "2024-05-28T01:57:58+00:00", + "update_time": "2024-05-28T01:57:58+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index efd995b3280..81f41bc1fdc 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -146,6 +146,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-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.dehumidifer_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.mock_device_iddefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-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.dehumidifer_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.mock_device_idtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index cbd3c997625..2a7ea120dd5 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -49,6 +49,110 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifer', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer', + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index c22005e123d..3389f927eb4 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -56,3 +56,113 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifer', + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index e8337fb4fbf..2c5b0e86619 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -117,6 +117,67 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifer_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.mock_device_idcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifer_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 8cf51062a73..530c9fccde2 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -52,6 +52,59 @@ 'state': '47.0', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_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.dehumidifer_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.mock_device_idhumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifer Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifer_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index bf970a6ffbb..1ba823e192d 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -48,6 +48,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-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': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.mock_device_idchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-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': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From bcec29763f46d4a925e5865884e32cb6d75d6a14 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 16:16:36 +1000 Subject: [PATCH 0273/1118] Fix button platform parent class in Teslemetry (#148863) --- homeassistant/components/teslemetry/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index cf1d6157ec1..12772b894b6 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehiclePollingEntity +from .entity import TeslemetryVehicleStreamEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -74,7 +74,7 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" api: Vehicle From 9db5b0b3b75d1b7f616e2e2d6e64c6e6dcf52c1a Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:51:16 +0200 Subject: [PATCH 0274/1118] Validate selectors in the service helper (#148857) --- homeassistant/helpers/service.py | 3 +++ tests/helpers/test_service.py | 46 +++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3186c211eaa..f9c846c60fa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ACTION, CONF_ENTITY_ID, + CONF_SELECTOR, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, @@ -54,6 +55,7 @@ from . import ( config_validation as cv, device_registry, entity_registry, + selector, target as target_helpers, template, translation, @@ -166,6 +168,7 @@ def validate_supported_feature(supported_feature: str) -> Any: # to their values. Full validation is done by hassfest.services _FIELD_SCHEMA = vol.Schema( { + vol.Optional(CONF_SELECTOR): selector.validate_selector, vol.Optional("filter"): { vol.Optional("attribute"): { vol.Required(str): [vol.All(str, validate_attribute_option)], diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 0191827cd58..f4d0846c262 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -987,7 +987,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: "test_domain": { "test_service": { "description": "", - "fields": {"test": {"selector": {"text": None}}}, + "fields": {"test": {"selector": {"text": {}}}}, "name": "", } } @@ -1013,6 +1013,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME advanced_stuff: fields: temperature: @@ -1024,6 +1031,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ domain = "test_domain" @@ -1065,7 +1079,20 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + }, + }, }, }, }, @@ -1074,7 +1101,20 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + }, + }, }, }, "name": "", From d8de6e34dde374c3dec8edde22185db5358c1400 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:24:20 +0200 Subject: [PATCH 0275/1118] Add support for Tuya ks category (tower fan) (#148811) --- homeassistant/components/tuya/fan.py | 22 +++- homeassistant/components/tuya/light.py | 9 ++ homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 6 + .../tuya/fixtures/ks_tower_fan.json | 107 ++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 64 +++++++++++ .../components/tuya/snapshots/test_light.ambr | 57 ++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++++ 8 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 tests/components/tuya/fixtures/ks_tower_fan.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f96ea2c0a65..90f4132cef0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -26,11 +26,23 @@ from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData TUYA_SUPPORT_TYPE = { - "cs", # Dehumidifier - "fs", # Fan - "fsd", # Fan with Light - "fskg", # Fan wall switch - "kj", # Air Purifier + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs", + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs", + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd", + # Fan wall switch + "fskg", + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj", + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks", } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3f8fc7d0fb9..b6d0332e03a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -242,6 +242,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Unknown light product # Found as VECINO RGBW as provided by diagnostics # Not documented diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f455424c2c1..bfe80ec67bf 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -431,6 +431,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + ), + ), # Alarm Host # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk "mal": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 129930b810f..6427a69cdea 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -79,6 +79,12 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "ks_tower_fan": [ + # https://github.com/orgs/home-assistant/discussions/329 + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, + ], "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, diff --git a/tests/components/tuya/fixtures/ks_tower_fan.json b/tests/components/tuya/fixtures/ks_tower_fan.json new file mode 100644 index 00000000000..071596e8e6c --- /dev/null +++ b/tests/components/tuya/fixtures/ks_tower_fan.json @@ -0,0 +1,107 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Tower Fan CA-407G Smart", + "category": "ks", + "product_id": "j9fa8ahzac8uvlfl", + "product_name": "Tower Fan CA-407G Smart", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-14T11:22:54+00:00", + "create_time": "2025-07-14T11:22:54+00:00", + "update_time": "2025-07-14T11:22:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 721, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "fan_speed": 5, + "mode": "ordinary", + "switch_horizontal": true, + "anion": false, + "light": true, + "countdown_left": 0 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 2a7ea120dd5..ff795c150c9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -210,3 +210,67 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.tower_fan_ca_407g_smart', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart', + 'oscillating': True, + 'percentage': 37, + 'percentage_step': 1.0, + 'preset_mode': 'ordinary', + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b9395b3d682..b83e9484853 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,3 +56,60 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-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': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.mock_device_idlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tower Fan CA-407G Smart Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1ba823e192d..4e6af0fa7d3 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -677,6 +677,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-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.tower_fan_ca_407g_smart_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', + }), + 'context': , + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From fae6b375cdf854de24e507163efd5013c6a2128c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:39:22 +0200 Subject: [PATCH 0276/1118] Fix incorrectly rejected device classes in tuya (#148596) --- homeassistant/components/number/__init__.py | 1 + homeassistant/components/tuya/number.py | 19 ++++++++++- homeassistant/components/tuya/sensor.py | 11 +++++++ .../tuya/snapshots/test_sensor.ambr | 32 ++++++++++++++----- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 3e9d3448af2..054f888ba33 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -39,6 +39,7 @@ from .const import ( # noqa: F401 DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_SET_VALUE, diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index cb248d42739..4fb180ffd08 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -5,6 +5,7 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,7 +16,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + LOGGER, + TUYA_DISCOVERY_NEW, + DPCode, + DPType, +) from .entity import TuyaEntity from .models import IntegerTypeData @@ -371,6 +379,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in NUMBER_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -378,6 +389,12 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in number entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9caf642d403..d1220e08728 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -8,6 +8,7 @@ from tuya_sharing import CustomerDevice, Manager from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -32,6 +33,7 @@ from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, DOMAIN, + LOGGER, TUYA_DISCOVERY_NEW, DPCode, DPType, @@ -1438,6 +1440,9 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in SENSOR_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -1445,6 +1450,12 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in sensor entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 530c9fccde2..f0350f12c48 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -181,8 +181,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Filter duration', 'platform': 'tuya', @@ -197,6 +200,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', 'state_class': , 'unit_of_measurement': 'min', @@ -233,8 +237,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'UV runtime', 'platform': 'tuya', @@ -249,6 +256,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', 'state_class': , 'unit_of_measurement': 's', @@ -333,8 +341,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water pump duration', 'platform': 'tuya', @@ -349,6 +360,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', 'state_class': , 'unit_of_measurement': 'min', @@ -385,8 +397,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage duration', 'platform': 'tuya', @@ -401,6 +416,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', 'state_class': , 'unit_of_measurement': 'min', @@ -509,7 +525,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-state] @@ -518,7 +534,7 @@ 'device_class': 'power', 'friendly_name': 'HVAC Meter Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.hvac_meter_power', @@ -683,7 +699,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.mocked_device_idcur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] @@ -692,7 +708,7 @@ 'device_class': 'power', 'friendly_name': '一路带计量磁保持通断器 Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', From bafd342d5dfd741786b4c6e9feca7059dcfceca9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:54:44 +0200 Subject: [PATCH 0277/1118] Add initial support for tuya cwjwq (#148420) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/select.py | 9 ++ homeassistant/components/tuya/sensor.py | 9 ++ homeassistant/components/tuya/strings.json | 16 +++ homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 6 ++ .../fixtures/cwjwq_smart_odor_eliminator.json | 66 ++++++++++++ .../tuya/snapshots/test_select.ambr | 57 ++++++++++ .../tuya/snapshots/test_sensor.ambr | 101 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++ 10 files changed, 321 insertions(+) create mode 100644 tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 61da1239554..863ef451eaa 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -406,6 +406,7 @@ class DPCode(StrEnum): WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode WORK_POWER = "work_power" + WORK_STATE_E = "work_state_e" @dataclass diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 4ad4355f876..22229b3f6bf 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -55,6 +55,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SelectEntityDescription( + key=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="odor_elimination_mode", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index d1220e08728..a4e1e931a5f 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -220,6 +220,15 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + TuyaSensorEntityDescription( + key=DPCode.WORK_STATE_E, + translation_key="odor_elimination_status", + ), + *BATTERY_SENSORS, + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a5302b2e88b..d5ccfffb79c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -485,6 +485,13 @@ "level_9": "Level 9", "level_10": "High" } + }, + "odor_elimination_mode": { + "name": "Odor elimination mode", + "state": { + "smart": "Smart", + "interim": "Interim" + } } }, "sensor": { @@ -697,6 +704,15 @@ }, "water_time": { "name": "Water usage duration" + }, + "odor_elimination_status": { + "name": "Status", + "state": { + "work": "Working", + "standby": "[%key:common::state::standby%]", + "charging": "[%key:common::state::charging%]", + "charge_done": "Charge done" + } } }, "switch": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index bfe80ec67bf..2cc7970d45a 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -85,6 +85,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 6427a69cdea..f0e2596fc81 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -45,6 +45,12 @@ DEVICE_MOCKS = { Platform.FAN, Platform.HUMIDIFIER, ], + "cwjwq_smart_odor_eliminator": [ + # https://github.com/orgs/home-assistant/discussions/79 + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], "cwwsq_cleverio_pf100": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, diff --git a/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json new file mode 100644 index 00000000000..a4a9fc6aaff --- /dev/null +++ b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1750837476328i3TNXQ", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6574iutyikgwkx", + "name": "Smart Odor Eliminator-Pro", + "category": "cwjwq", + "product_id": "agwu93lr", + "product_name": "Smart Odor Eliminator-Pro", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-25T07:43:07+00:00", + "create_time": "2025-06-25T07:43:07+00:00", + "update_time": "2025-06-25T07:43:07+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + }, + "work_state_e": { + "type": "Enum", + "value": { + "range": ["work", "standby", "charging", "charge_done"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "work_mode": "smart", + "work_state_e": "work", + "battery_percentage": 43 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 2c5b0e86619..6f45f63dcfa 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -178,6 +178,63 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_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': 'Odor elimination mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_mode', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'context': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f0350f12c48..b637839333d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -105,6 +105,107 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_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.smart_odor_eliminator_pro_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf6574iutyikgwkxbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_status', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_state_e', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Status', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 4e6af0fa7d3..1ed4e9fdc1b 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -146,6 +146,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-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.smart_odor_eliminator_pro_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf6574iutyikgwkxswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Switch', + }), + 'context': , + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 84e3dac406e3e28e1e4d6f135f00acda0e2bce0d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 18:05:17 +1000 Subject: [PATCH 0278/1118] Update vehicle type handling in Teslemetry (#148862) --- .../components/teslemetry/__init__.py | 2 +- .../components/teslemetry/binary_sensor.py | 2 +- .../components/teslemetry/climate.py | 4 +- homeassistant/components/teslemetry/cover.py | 11 +- .../components/teslemetry/device_tracker.py | 2 +- homeassistant/components/teslemetry/lock.py | 4 +- .../components/teslemetry/media_player.py | 2 +- homeassistant/components/teslemetry/number.py | 2 +- homeassistant/components/teslemetry/select.py | 2 +- homeassistant/components/teslemetry/sensor.py | 4 +- homeassistant/components/teslemetry/switch.py | 3 +- homeassistant/components/teslemetry/update.py | 2 +- tests/components/teslemetry/conftest.py | 7 +- tests/components/teslemetry/const.py | 34 +- .../snapshots/test_binary_sensor.ambr | 2382 +---------------- .../teslemetry/snapshots/test_climate.ambr | 3 +- .../teslemetry/test_binary_sensor.py | 2 + tests/components/teslemetry/test_climate.py | 1 - tests/components/teslemetry/test_cover.py | 2 +- .../teslemetry/test_device_tracker.py | 1 - .../components/teslemetry/test_diagnostics.py | 3 + tests/components/teslemetry/test_init.py | 12 +- .../teslemetry/test_media_player.py | 1 - tests/components/teslemetry/test_sensor.py | 7 +- 24 files changed, 105 insertions(+), 2390 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 3ffc6c43efb..688a254a731 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) - poll = product["command_signing"] == "off" + poll = vehicle_metadata[vin].get("polling", False) vehicles.append( TeslemetryVehicleData( diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 6905cefdc30..5db73c7aa06 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -542,7 +542,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 1bc52b23026..000e1b136c8 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -67,7 +67,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -77,7 +77,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index f6ff71ab0cc..5c86d6e19fe 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -45,7 +45,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -53,7 +53,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -63,7 +63,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -73,7 +73,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -82,7 +82,8 @@ async def async_setup_entry( ( TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + if vehicle.poll + and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") ), ) ) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index eb2c220ebbd..0e1b3edf69a 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -89,7 +89,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index fda52357f5c..7e98d6338ba 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -42,7 +42,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -52,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index bf1fffed583..9ffc02e4307 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + if vehicle.poll or vehicle.firmware < "2025.2.6" else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index bb9f5b588a0..bccefcaf6cb 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -145,7 +145,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index c24c47feb2e..fec54b75880 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -180,7 +180,7 @@ async def async_setup_entry( TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 + if vehicle.poll or vehicle.firmware < "2024.26" or description.streaming_listener is None else TeslemetryStreamingSelectEntity( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b50c9b4d0ce..1ffe073cc5c 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -1565,7 +1565,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): @@ -1575,7 +1575,7 @@ async def async_setup_entry( for time_description in VEHICLE_TIME_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and vehicle.firmware >= time_description.streaming_firmware ): entities.append( diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index f607429be46..aae973cf315 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -147,8 +147,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 - or vehicle.firmware < description.streaming_firmware + if vehicle.poll or vehicle.firmware < description.streaming_firmware else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 144a97039fc..7e0b727ba79 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index b9b5efae6ec..ffcc74d5587 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -14,6 +14,7 @@ from .const import ( ENERGY_HISTORY, LIVE_STATUS, METADATA, + METADATA_LEGACY, PRODUCTS, SITE_INFO, VEHICLE_DATA, @@ -53,9 +54,9 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True - ) as mock_pre2021: - yield mock_pre2021 + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA_LEGACY + ) as mock_products: + yield mock_products @pytest.fixture(autouse=True) diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 3bfa452e38d..7b671bbeaaa 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -37,6 +37,32 @@ COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERR RESPONSE_OK = {"response": {}, "error": None} METADATA = { + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "vehicle_location", + "energy_device_data", + "energy_cmds", + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": True, + "access": True, + "polling": False, + "firmware": "2026.0.0", + "discounted": False, + "fleet_telemetry": "1.0.2", + "name": "Home Assistant", + } + }, +} +METADATA_LEGACY = { "uid": "abc-123", "region": "NA", "scopes": [ @@ -56,6 +82,9 @@ METADATA = { "access": True, "polling": True, "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } @@ -68,7 +97,10 @@ METADATA_NOSCOPE = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 06ec0a60434..2b920a0cfdc 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -240,102 +240,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-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.test_automatic_blind_spot_camera', - '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': 'Automatic blind spot camera', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_blind_spot_camera', - 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-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.test_automatic_emergency_braking_off', - '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': 'Automatic emergency braking off', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_emergency_braking_off', - 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,151 +286,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-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.test_blind_spot_collision_warning_chime', - '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': 'Blind spot collision warning chime', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'blind_spot_collision_warning_chime', - 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-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.test_bms_full_charge', - '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': 'BMS full charge', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bms_full_charge_complete', - 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-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.test_brake_pedal', - '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': 'Brake pedal', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'brake_pedal', - 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] @@ -578,55 +338,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_cellular-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.test_cellular', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cellular', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cellular', - 'unique_id': 'LRW3F7EK4NC700000-cellular', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_cellular-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -673,103 +384,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-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.test_charge_enable_request', - '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': 'Charge enable request', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_enable_request', - 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-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.test_charge_port_cold_weather_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': 'Charge port cold weather mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_port_cold_weather_mode', - 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] @@ -817,7 +432,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -869,390 +484,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-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.test_dc_to_dc_converter', - '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': 'DC to DC converter', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'dc_dc_enable', - 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-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.test_defrost_for_preconditioning', - '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': 'Defrost for preconditioning', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'defrost_for_preconditioning', - 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-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.test_drive_rail', - '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': 'Drive rail', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'drive_rail', - 'unique_id': 'LRW3F7EK4NC700000-drive_rail', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-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.test_driver_seat_belt', - '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': 'Driver seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-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.test_driver_seat_occupied', - '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': 'Driver seat occupied', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_occupied', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-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.test_emergency_lane_departure_avoidance', - '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': 'Emergency lane departure avoidance', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'emergency_lane_departure_avoidance', - 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-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.test_european_vehicle', - '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': 'European vehicle', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'europe_vehicle', - 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-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.test_fast_charger_present', - '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': 'Fast charger present', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'fast_charger_present', - 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1299,7 +530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -1348,7 +579,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -1397,7 +628,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -1446,633 +677,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_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': , - 'entity_id': 'binary_sensor.test_gps_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': 'GPS state', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'gps_state', - 'unique_id': 'LRW3F7EK4NC700000-gps_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-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.test_guest_mode_enabled', - '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': 'Guest mode enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'guest_mode_enabled', - 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-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.test_hazard_lights', - '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': 'Hazard lights', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_hazards_active', - 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-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.test_high_beams', - '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': 'High beams', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_high_beams', - 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-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.test_high_voltage_interlock_loop_fault', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'High voltage interlock loop fault', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvil', - 'unique_id': 'LRW3F7EK4NC700000-hvil', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-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.test_homelink_nearby', - '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': 'Homelink nearby', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'homelink_nearby', - 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-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.test_hvac_auto_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': 'HVAC auto mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_auto_mode', - 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-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.test_located_at_favorite', - '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': 'Located at favorite', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_favorite', - 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-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.test_located_at_home', - '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': 'Located at home', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_home', - 'unique_id': 'LRW3F7EK4NC700000-located_at_home', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-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.test_located_at_work', - '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': 'Located at work', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_work', - 'unique_id': 'LRW3F7EK4NC700000-located_at_work', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-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.test_offroad_lightbar', - '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': 'Offroad lightbar', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'offroad_lightbar_present', - 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-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.test_passenger_seat_belt', - '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': 'Passenger seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-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.test_pin_to_drive_enabled', - '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': 'PIN to Drive enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'pin_to_drive_enabled', - 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -2168,55 +773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-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.test_rear_display_hvac', - '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': 'Rear display HVAC', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_display_hvac_enabled', - 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -2265,7 +822,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -2314,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -2363,7 +920,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -2412,103 +969,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-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.test_remote_start', - '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': 'Remote start', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'remote_start_enabled', - 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-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.test_right_hand_drive', - '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': 'Right hand drive', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'right_hand_drive', - 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -2556,151 +1017,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-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.test_seat_vent_enabled', - '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': 'Seat vent enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'seat_vent_enabled', - 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-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.test_service_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': 'Service mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'service_mode', - 'unique_id': 'LRW3F7EK4NC700000-service_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-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.test_speed_limited', - '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': 'Speed limited', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'speed_limit_mode', - 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -2749,55 +1066,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-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.test_supercharger_session_trip_planner', - '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': 'Supercharger session trip planner', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'supercharger_session_trip_planner', - 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] @@ -3093,103 +1362,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-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.test_wi_fi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wi-Fi', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wifi', - 'unique_id': 'LRW3F7EK4NC700000-wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-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.test_wiper_heat', - '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': 'Wiper heat', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wiper_heat_enabled', - 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3256,32 +1428,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3293,46 +1439,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] @@ -3349,20 +1456,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3374,33 +1467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] @@ -3413,7 +1480,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -3430,110 +1497,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3545,7 +1508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -3559,7 +1522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -3573,7 +1536,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -3587,178 +1550,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -3784,20 +1576,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -3811,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -3825,7 +1604,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -3839,7 +1618,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -3853,33 +1632,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -3892,46 +1645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -3945,20 +1659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] @@ -4044,33 +1745,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 1aa68b59ee3..11708be7e39 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -407,9 +407,8 @@ ]), 'max_temp': 40, 'min_temp': 30, - 'supported_features': , + 'supported_features': , 'target_temp_step': 5, - 'temperature': None, }), 'context': , 'entity_id': 'climate.test_cabin_overheat_protection', diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0f5588fe323..b3871c52420 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -23,6 +23,7 @@ async def test_binary_sensor( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" @@ -37,6 +38,7 @@ async def test_binary_sensor_refresh( entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 27bed45c51f..f6c158fbd80 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -273,7 +273,6 @@ async def test_climate_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index e3933931c9f..2ba6d391cfc 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -55,7 +55,6 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -67,6 +66,7 @@ async def test_cover_noscope( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index ea0ee08e64f..7edabe9ec6f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -49,7 +49,6 @@ async def test_device_tracker_noscope( entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index 18182b14321..5737a5ebe2c 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,5 +1,7 @@ """Test the Telemetry Diagnostics.""" +from unittest.mock import AsyncMock + from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -18,6 +20,7 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Test diagnostics.""" diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 54c9ca0dad9..e177865d2f9 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -14,7 +14,13 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -72,6 +78,7 @@ async def test_vehicle_refresh_error( mock_vehicle_data: AsyncMock, side_effect: TeslaFleetError, state: ConfigEntryState, + mock_legacy: AsyncMock, ) -> None: """Test coordinator refresh with an error.""" mock_vehicle_data.side_effect = side_effect @@ -107,6 +114,7 @@ async def test_energy_site_refresh_error( assert entry.state is state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, @@ -121,7 +129,7 @@ async def test_vehicle_stream( assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE mock_add_listener.send( { diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ab8f21ceda4..8b7a91cfe2c 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 296f9e8bff4..e8f413433c1 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,6 +1,6 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,6 +26,7 @@ async def test_sensors( entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the sensor entities with the legacy polling are correct.""" @@ -33,9 +34,7 @@ async def test_sensors( async_fire_time_changed(hass) await hass.async_block_till_done() - # Force the vehicle to use polling - with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): - entry = await setup_platform(hass, [Platform.SENSOR]) + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) From 6833bf190002ecd3dc21e5f80e788d94a2a89e0a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:15:44 +0200 Subject: [PATCH 0279/1118] Add battery status and configuration entities to Tuya thermostat (wk) (#148821) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/number.py | 9 +++ homeassistant/components/tuya/sensor.py | 3 + homeassistant/components/tuya/strings.json | 3 + tests/components/tuya/__init__.py | 2 + .../tuya/snapshots/test_number.ambr | 57 +++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++++++++++++ 7 files changed, 128 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 863ef451eaa..b8bb5ea483f 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -352,6 +352,7 @@ class DPCode(StrEnum): TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" TEMP_CONTROLLER = "temp_controller" + TEMP_CORRECTION = "temp_correction" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_CURRENT_EXTERNAL = ( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 4fb180ffd08..68777d75a90 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -295,6 +295,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + NumberEntityDescription( + key=DPCode.TEMP_CORRECTION, + translation_key="temp_correction", + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index a4e1e931a5f..6e8da29ef53 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1077,6 +1077,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": (*BATTERY_SENSORS,), # Two-way temperature and humidity switch # "MOES Temperature and Humidity Smart Switch Module MS-103" # Documentation not found diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index d5ccfffb79c..ee1df183f36 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -219,6 +219,9 @@ }, "down_delay": { "name": "Down delay" + }, + "temp_correction": { + "name": "Temperature correction" } }, "select": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index f0e2596fc81..5f91571f35d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -126,6 +126,8 @@ DEVICE_MOCKS = { "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ], "wsdcg_temperature_humidity": [ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 6d741e4e76c..de65f6e6c6b 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -56,3 +56,60 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.5', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index b637839333d..6bf3bf67a32 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1966,6 +1966,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_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.wifi_smart_gas_boiler_thermostat_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 033d8b3dfb6380fe382e0edb1b34ac4318c5f090 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:38:43 +0200 Subject: [PATCH 0280/1118] Add snapshot tests for tuya co2bj and gyd categories (#148872) --- tests/components/tuya/__init__.py | 12 + .../tuya/fixtures/co2bj_air_detector.json | 174 ++++++++++++ .../tuya/fixtures/gyd_night_light.json | 266 +++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++ .../components/tuya/snapshots/test_light.ambr | 73 +++++ .../tuya/snapshots/test_number.ambr | 59 ++++ .../tuya/snapshots/test_select.ambr | 61 ++++ .../tuya/snapshots/test_sensor.ambr | 267 ++++++++++++++++++ .../components/tuya/snapshots/test_siren.ambr | 50 ++++ tests/components/tuya/test_siren.py | 55 ++++ 10 files changed, 1066 insertions(+) create mode 100644 tests/components/tuya/fixtures/co2bj_air_detector.json create mode 100644 tests/components/tuya/fixtures/gyd_night_light.json create mode 100644 tests/components/tuya/snapshots/test_siren.ambr create mode 100644 tests/components/tuya/test_siren.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5f91571f35d..086a6a3832a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -23,6 +23,14 @@ DEVICE_MOCKS = { Platform.COVER, Platform.LIGHT, ], + "co2bj_air_detector": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + ], "cs_arete_two_12l_dehumidifier_air_purifier": [ Platform.BINARY_SENSOR, Platform.FAN, @@ -75,6 +83,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "gyd_night_light": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.LIGHT, + ], "kg_smart_valve": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/co2bj_air_detector.json b/tests/components/tuya/fixtures/co2bj_air_detector.json new file mode 100644 index 00000000000..8d7e744fb52 --- /dev/null +++ b/tests/components/tuya/fixtures/co2bj_air_detector.json @@ -0,0 +1,174 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb14fd1dd93ca2ea34vpin", + "name": "AQI", + "category": "co2bj", + "product_id": "yrr3eiyiacm31ski", + "product_name": "AIR_DETECTOR ", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2025-01-02T05:14:50+00:00", + "create_time": "2025-01-02T05:14:50+00:00", + "update_time": "2025-01-02T05:14:50+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "co2_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co2_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 5000, + "scale": 0, + "step": 1 + } + }, + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -9, + "max": 199, + "scale": 0, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pm25_value": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "voc_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + }, + "ch2o_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + } + }, + "status": { + "co2_state": "normal", + "co2_value": 541, + "alarm_volume": "low", + "alarm_time": 1, + "alarm_switch": false, + "battery_percentage": 100, + "alarm_bright": 98, + "temp_current": 26, + "humidity_value": 53, + "pm25_value": 17, + "voc_value": 18, + "ch2o_value": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/gyd_night_light.json b/tests/components/tuya/fixtures/gyd_night_light.json new file mode 100644 index 00000000000..28f2b8e8f46 --- /dev/null +++ b/tests/components/tuya/fixtures/gyd_night_light.json @@ -0,0 +1,266 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + + "id": "eb3e988f33c233290cfs3l", + "name": "Colorful PIR Night Light", + "category": "gyd", + "product_id": "lgekqfxdabipm3tn", + "product_name": "Colorful PIR Night Light", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:02:37+00:00", + "create_time": "2024-07-18T12:02:37+00:00", + "update_time": "2024-07-18T12:02:37+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "pir_state": { + "type": "Enum", + "value": { + "range": ["pir", "none"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 1000, + "temp_value": 1, + "colour_data": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown": 0, + "device_mode": "auto", + "pir_state": "none", + "cds": "5lux", + "pir_sensitivity": "middle", + "pir_delay": 30, + "switch_pir": true, + "standby_time": 1, + "standby_bright": 146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 81f41bc1fdc..267f61aabd0 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-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.aqi_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinco2_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'AQI Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.aqi_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b83e9484853..5b0afb289ac 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,6 +56,79 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.colorful_pir_night_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb3e988f33c233290cfs3lswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Colorful PIR Night Light', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.colorful_pir_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index de65f6e6c6b..125a0680de9 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqi_alarm_duration', + '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 duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_duration', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AQI Alarm duration', + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.aqi_alarm_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 6f45f63dcfa..a2d52a893c9 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -56,6 +56,67 @@ 'state': 'forward', }) # --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aqi_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.aqi_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 6bf3bf67a32..57e73eccda5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,271 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AQI Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-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.aqi_formaldehyde', + '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': 'Formaldehyde', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'formaldehyde', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinch2o_value', + 'unit_of_measurement': 'mg/m3', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Formaldehyde', + 'state_class': , + 'unit_of_measurement': 'mg/m3', + }), + 'context': , + 'entity_id': 'sensor.aqi_formaldehyde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AQI Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpintemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AQI Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqi_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-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.aqi_volatile_organic_compounds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinvoc_value', + 'unit_of_measurement': 'mg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds', + 'friendly_name': 'AQI Volatile organic compounds', + 'state_class': , + 'unit_of_measurement': 'mg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr new file mode 100644 index 00000000000..8a6faa31c43 --- /dev/null +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': , + 'entity_id': 'siren.aqi', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.aqi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py new file mode 100644 index 00000000000..69ccc14e407 --- /dev/null +++ b/tests/components/tuya/test_siren.py @@ -0,0 +1,55 @@ +"""Test Tuya siren platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 8a73511b02f79de772314509a82dd4378a3aeeb0 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:44:04 +0200 Subject: [PATCH 0281/1118] Add inactive reason sensor to Husqvarna Automower (#147684) --- .../components/husqvarna_automower/icons.json | 3 + .../components/husqvarna_automower/sensor.py | 23 ++++++- .../husqvarna_automower/strings.json | 8 +++ .../snapshots/test_sensor.ambr | 60 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e1b355959d9..e9d023bd3cc 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -24,6 +24,9 @@ "error": { "default": "mdi:alert-circle-outline" }, + "inactive_reason": { + "default": "mdi:sleep" + }, "my_lawn_last_time_completed": { "default": "mdi:clock-outline" }, diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0a059fdd706..72f65320efd 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,7 +7,13 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea +from aioautomower.model import ( + InactiveReasons, + MowerAttributes, + MowerModes, + RestrictedReasons, + WorkArea, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -166,6 +172,13 @@ ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) ) +INACTIVE_REASONS: list = [ + InactiveReasons.NONE, + InactiveReasons.PLANNING, + InactiveReasons.SEARCHING_FOR_SATELLITES, +] + + RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, RestrictedReasons.DAILY_LIMIT, @@ -389,6 +402,14 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( option_fn=lambda data: RESTRICTED_REASONS, value_fn=attrgetter("planner.restricted_reason"), ), + AutomowerSensorEntityDescription( + key="inactive_reason", + translation_key="inactive_reason", + exists_fn=lambda data: data.capabilities.work_areas, + device_class=SensorDeviceClass.ENUM, + option_fn=lambda data: INACTIVE_REASONS, + value_fn=attrgetter("mower.inactive_reason"), + ), AutomowerSensorEntityDescription( key="work_area", translation_key="work_area", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9e808c66878..62843d67ae2 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -213,6 +213,14 @@ "zone_generator_problem": "Zone generator problem" } }, + "inactive_reason": { + "name": "Inactive reason", + "state": { + "none": "No inactivity", + "planning": "Planning", + "searching_for_satellites": "Searching for satellites" + } + }, "my_lawn_last_time_completed": { "name": "My lawn last time completed" }, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 109e6614545..0fe46c24254 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -585,6 +585,66 @@ 'state': '40', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inactive reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_reason', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_inactive_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Inactive reason', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a57d48fd3101020a07407e7919dc9d8f67bff7a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 10:55:28 +0200 Subject: [PATCH 0282/1118] Add OpenRouter integration (#143098) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/open_router/__init__.py | 58 +++++++ .../components/open_router/config_flow.py | 118 ++++++++++++++ homeassistant/components/open_router/const.py | 6 + .../components/open_router/conversation.py | 133 ++++++++++++++++ .../components/open_router/manifest.json | 13 ++ .../components/open_router/quality_scale.yaml | 88 +++++++++++ .../components/open_router/strings.json | 37 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 4 + requirements_test_all.txt | 4 + tests/components/open_router/__init__.py | 13 ++ tests/components/open_router/conftest.py | 128 +++++++++++++++ .../snapshots/test_conversation.ambr | 16 ++ .../open_router/test_config_flow.py | 146 ++++++++++++++++++ .../open_router/test_conversation.py | 52 +++++++ 19 files changed, 836 insertions(+) create mode 100644 homeassistant/components/open_router/__init__.py create mode 100644 homeassistant/components/open_router/config_flow.py create mode 100644 homeassistant/components/open_router/const.py create mode 100644 homeassistant/components/open_router/conversation.py create mode 100644 homeassistant/components/open_router/manifest.json create mode 100644 homeassistant/components/open_router/quality_scale.yaml create mode 100644 homeassistant/components/open_router/strings.json create mode 100644 tests/components/open_router/__init__.py create mode 100644 tests/components/open_router/conftest.py create mode 100644 tests/components/open_router/snapshots/test_conversation.ambr create mode 100644 tests/components/open_router/test_config_flow.py create mode 100644 tests/components/open_router/test_conversation.py diff --git a/.strict-typing b/.strict-typing index 626fc10a4c2..18e72162a23 100644 --- a/.strict-typing +++ b/.strict-typing @@ -377,6 +377,7 @@ homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* +homeassistant.components.open_router.* homeassistant.components.openai_conversation.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* diff --git a/CODEOWNERS b/CODEOWNERS index c0bed7f100a..05c17b5498d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1102,6 +1102,8 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck +/homeassistant/components/open_router/ @joostlek +/tests/components/open_router/ @joostlek /homeassistant/components/openai_conversation/ @balloob /tests/components/openai_conversation/ @balloob /homeassistant/components/openerz/ @misialq diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py new file mode 100644 index 00000000000..477fabca54c --- /dev/null +++ b/homeassistant/components/open_router/__init__.py @@ -0,0 +1,58 @@ +"""The OpenRouter integration.""" + +from __future__ import annotations + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import LOGGER + +PLATFORMS = [Platform.CONVERSATION] + +type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Set up OpenRouter from a config entry.""" + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(hass), + ) + + # Cache current platform data which gets added to each request (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + + try: + async for _ in client.with_options(timeout=10.0).models.list(): + break + except AuthenticationError as err: + LOGGER.error("Invalid API key: %s", err) + raise ConfigEntryError("Invalid API key") from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Unload OpenRouter.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py new file mode 100644 index 00000000000..48d37d79cc6 --- /dev/null +++ b/homeassistant/components/open_router/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for OpenRouter integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from openai import AsyncOpenAI +from python_open_router import OpenRouterClient, OpenRouterError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenRouter.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = OpenRouterClient( + user_input[CONF_API_KEY], async_get_clientsession(self.hass) + ) + try: + await client.get_key_data() + except OpenRouterError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OpenRouter", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class ConversationFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.options: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + return self.async_create_entry( + title=self.options[user_input[CONF_MODEL]], data=user_input + ) + entry = self._get_entry() + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(self.hass), + ) + options = [] + async for model in client.with_options(timeout=10.0).models.list(): + options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined] + self.options[model.id] = model.name # type: ignore[attr-defined] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + } + ), + ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py new file mode 100644 index 00000000000..e357f28d6d5 --- /dev/null +++ b/homeassistant/components/open_router/const.py @@ -0,0 +1,6 @@ +"""Constants for the OpenRouter integration.""" + +import logging + +DOMAIN = "open_router" +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py new file mode 100644 index 00000000000..48720e7c829 --- /dev/null +++ b/homeassistant/components/open_router/conversation.py @@ -0,0 +1,133 @@ +"""Conversation support for OpenRouter.""" + +from typing import Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenRouterConfigEntry +from .const import DOMAIN, LOGGER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [OpenRouterConversationEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return None + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + return ChatCompletionAssistantMessageParam( + role="assistant", content=content.content + ) + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +class OpenRouterConversationEntity(conversation.ConversationEntity): + """OpenRouter conversation agent.""" + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + None, + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + user=chat_log.conversation_id, + extra_headers={ + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + ) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + result_message = result.choices[0].message + + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=result_message.content, + ) + ) + + intent_response = intent.IntentResponse(language=user_input.language) + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json new file mode 100644 index 00000000000..64b7319a902 --- /dev/null +++ b/homeassistant/components/open_router/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "open_router", + "name": "OpenRouter", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/open_router", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==1.93.3", "python-open-router==0.2.0"] +} diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml new file mode 100644 index 00000000000..9b71a29dc6b --- /dev/null +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not subscribe to events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json new file mode 100644 index 00000000000..93936b4d92b --- /dev/null +++ b/homeassistant/components/open_router/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OpenRouter API key" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "conversation": { + "step": { + "user": { + "description": "Configure the new conversation agent", + "data": { + "model": "Model" + } + } + }, + "initiate_flow": { + "user": "Add conversation agent" + }, + "entry_type": "Conversation agent" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 92319af9617..49695b695ac 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -449,6 +449,7 @@ FLOWS = { "onkyo", "onvif", "open_meteo", + "open_router", "openai_conversation", "openexchangerates", "opengarage", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 277400bec02..480a88e1ae4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4621,6 +4621,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "open_router": { + "name": "OpenRouter", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "openai_conversation": { "name": "OpenAI Conversation", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 25039f7f386..bff6c93967e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3526,6 +3526,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.open_router.*] +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.openai_conversation.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1b8fc7b8801..4a79b0ad597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1596,6 +1596,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -2476,6 +2477,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.2.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4ff300a02..2b4fa6c91cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,6 +1364,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -2049,6 +2050,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.2.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/tests/components/open_router/__init__.py b/tests/components/open_router/__init__.py new file mode 100644 index 00000000000..3858e866315 --- /dev/null +++ b/tests/components/open_router/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the OpenRouter integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py new file mode 100644 index 00000000000..e2e0fbb2c37 --- /dev/null +++ b/tests/components/open_router/conftest.py @@ -0,0 +1,128 @@ +"""Fixtures for OpenRouter integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.open_router.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="OpenRouter", + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data={CONF_MODEL: "gpt-3.5-turbo"}, + subentry_id="ABCDEF", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ) + ], + ) + + +@dataclass +class Model: + """Mock model data.""" + + id: str + name: str + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with ( + patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client, + patch( + "homeassistant.components.open_router.config_flow.AsyncOpenAI", + new=mock_client, + ), + ): + client = mock_client.return_value + client.with_options = MagicMock() + client.with_options.return_value.models = MagicMock() + client.with_options.return_value.models.list.return_value = ( + get_generator_from_data( + [ + Model(id="gpt-4", name="GPT-4"), + Model(id="gpt-3.5-turbo", name="GPT-3.5 Turbo"), + ], + ) + ) + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +async def mock_open_router_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with patch( + "homeassistant.components.open_router.config_flow.OpenRouterClient", + autospec=True, + ) as mock_client: + client = mock_client.return_value + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +async def get_generator_from_data[DataT](items: list[DataT]) -> AsyncGenerator[DataT]: + """Return async generator.""" + for item in items: + yield item diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..90f9097e854 --- /dev/null +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py new file mode 100644 index 00000000000..6be258dca38 --- /dev/null +++ b/tests/components/open_router/test_config_flow.py @@ -0,0 +1,146 @@ +"""Test the OpenRouter config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_open_router import OpenRouterError + +from homeassistant.components.open_router.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "bla"} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OpenRouterError("exception"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors from the OpenRouter API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_open_router_client.get_key_data.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_open_router_client.get_key_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry already exists.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_MODEL: "gpt-3.5-turbo"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(mock_config_entry.subentries)[0] + assert ( + ConfigSubentry( + data={CONF_MODEL: "gpt-3.5-turbo"}, + subentry_id=subentry_id, + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ) + in mock_config_entry.subentries.values() + ) diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py new file mode 100644 index 00000000000..043dae2ff30 --- /dev/null +++ b/tests/components/open_router/test_conversation.py @@ -0,0 +1,52 @@ +"""Tests for the OpenRouter integration.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert call["extra_headers"] == { + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + "X-Title": "Home Assistant", + } From fe8384719d931b4c3481fc450b7299e688f7f637 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:18:14 +0200 Subject: [PATCH 0283/1118] Bump pyenphase to 2.2.2 (#148870) --- 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 278045001fc..320179bf2df 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.1"], + "requirements": ["pyenphase==2.2.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 4a79b0ad597..f89f00451de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4fa6c91cf..8f3345ae688 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.everlights pyeverlights==0.1.0 From ce4a811b96256471e42067e8699914107f3eeabf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 11:55:50 +0200 Subject: [PATCH 0284/1118] Add `hydrological alert` sensor to IMGW-PIB integration (#148848) --- homeassistant/components/imgw_pib/icons.json | 3 + homeassistant/components/imgw_pib/sensor.py | 32 +++++++++ .../components/imgw_pib/strings.json | 35 ++++++++++ tests/components/imgw_pib/conftest.py | 10 ++- .../imgw_pib/snapshots/test_diagnostics.ambr | 10 +-- .../imgw_pib/snapshots/test_sensor.ambr | 65 +++++++++++++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index b9226276af6..0265c6c2ec0 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "hydrological_alert": { + "default": "mdi:alert-octagon-outline" + }, "water_flow": { "default": "mdi:waves-arrow-right" }, diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 1c49bfb2dc0..7084889220c 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any +from imgw_pib.const import HYDROLOGICAL_ALERTS_MAP, NO_ALERT from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( @@ -28,14 +30,36 @@ from .entity import ImgwPibEntity PARALLEL_UPDATES = 0 +def gen_alert_attributes(data: HydrologicalData) -> dict[str, Any] | None: + """Generate attributes for the alert entity.""" + if data.hydrological_alert.value == NO_ALERT: + return None + + return { + "level": data.hydrological_alert.level, + "probability": data.hydrological_alert.probability, + "valid_from": data.hydrological_alert.valid_from, + "valid_to": data.hydrological_alert.valid_to, + } + + @dataclass(frozen=True, kw_only=True) class ImgwPibSensorEntityDescription(SensorEntityDescription): """IMGW-PIB sensor entity description.""" value: Callable[[HydrologicalData], StateType] + attrs: Callable[[HydrologicalData], dict[str, Any] | None] | None = None SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="hydrological_alert", + translation_key="hydrological_alert", + device_class=SensorDeviceClass.ENUM, + options=list(HYDROLOGICAL_ALERTS_MAP.values()), + value=lambda data: data.hydrological_alert.value, + attrs=gen_alert_attributes, + ), ImgwPibSensorEntityDescription( key="water_flow", translation_key="water_flow", @@ -109,3 +133,11 @@ class ImgwPibSensorEntity(ImgwPibEntity, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value(self.coordinator.data) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.attrs: + return self.entity_description.attrs(self.coordinator.data) + + return None diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index fc92ca573ab..7adb1673c8a 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,41 @@ }, "entity": { "sensor": { + "hydrological_alert": { + "name": "Hydrological alert", + "state": { + "no_alert": "No alert", + "hydrological_drought": "Hydrological drought", + "rapid_water_level_rise": "Rapid water level rise" + }, + "state_attributes": { + "level": { + "name": "Level", + "state": { + "none": "None", + "orange": "Orange", + "red": "Red", + "yellow": "Yellow" + } + }, + "options": { + "state": { + "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", + "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" + } + }, + "probability": { + "name": "Probability" + }, + "valid_from": { + "name": "Valid from" + }, + "valid_to": { + "name": "Valid to" + } + } + }, "water_flow": { "name": "Water flow" }, diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index c3f87288573..0ba09c27e0e 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import NO_ALERT, Alert, HydrologicalData, SensorData +from imgw_pib import Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,7 +25,13 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), - hydrological_alert=Alert(value=NO_ALERT), + hydrological_alert=Alert( + value="rapid_water_level_rise", + valid_from=datetime(2024, 4, 27, 7, 0, tzinfo=UTC), + valid_to=datetime(2024, 4, 28, 11, 0, tzinfo=UTC), + level="yellow", + probability=80, + ), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index be2afee3da9..420a9300d3d 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -35,11 +35,11 @@ 'value': None, }), 'hydrological_alert': dict({ - 'level': None, - 'probability': None, - 'valid_from': None, - 'valid_to': None, - 'value': 'no_alert', + 'level': 'yellow', + 'probability': 80, + 'valid_from': '2024-04-27T07:00:00+00:00', + 'valid_to': '2024-04-28T11:00:00+00:00', + 'value': 'rapid_water_level_rise', }), 'latitude': None, 'longitude': None, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 97bb6eefef3..276ea41eecf 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrological alert', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hydrological_alert', + 'unique_id': '123_hydrological_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'enum', + 'friendly_name': 'River Name (Station Name) Hydrological alert', + 'level': 'yellow', + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + 'probability': 80, + 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), + 'valid_to': datetime.datetime(2024, 4, 28, 11, 0, tzinfo=datetime.timezone.utc), + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rapid_water_level_rise', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_flow-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 29e105b0ef985531c8561f5cbc8ca8f8f4c5de94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 16 Jul 2025 12:19:31 +0200 Subject: [PATCH 0285/1118] Set default mode for number selector to box (#148773) --- homeassistant/helpers/selector.py | 10 ++++++---- tests/helpers/test_selector.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0fa5403ad2b..7bd1ee9ddf3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1108,10 +1108,12 @@ class NumberSelectorMode(StrEnum): def validate_slider(data: Any) -> Any: """Validate configuration.""" - if data["mode"] == "box": - return data + has_min_max = "min" in data and "max" in data - if "min" not in data or "max" not in data: + if "mode" not in data: + data["mode"] = "slider" if has_min_max else "box" + + if data["mode"] == "slider" and not has_min_max: raise vol.Invalid("min and max are required in slider mode") return data @@ -1134,7 +1136,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): "any", vol.All(vol.Coerce(float), vol.Range(min=1e-3)) ), vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, - vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( + vol.Optional(CONF_MODE): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), vol.Optional("translation_key"): str, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 159f295ab2f..dc25206177b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -427,6 +427,7 @@ def test_assist_pipeline_selector_schema( ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), + ({}, (), ()), ], ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -434,10 +435,28 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("number", schema, valid_selections, invalid_selections) +def test_number_selector_schema_default_mode() -> None: + """Test number selector default mode set on min/max.""" + assert selector.selector({"number": {"min": 10, "max": 50}}).config == { + "mode": "slider", + "min": 10.0, + "max": 50.0, + "step": 1.0, + } + assert selector.selector({"number": {}}).config == { + "mode": "box", + "step": 1.0, + } + assert selector.selector({"number": {"min": "10"}}).config == { + "mode": "box", + "min": 10.0, + "step": 1.0, + } + + @pytest.mark.parametrize( "schema", [ - {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode ], ) From a6828898d165a439f23ed634579c6c2951431710 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 16 Jul 2025 12:25:10 +0200 Subject: [PATCH 0286/1118] Add sensor platform to SMHI (#139295) --- homeassistant/components/smhi/__init__.py | 2 +- homeassistant/components/smhi/coordinator.py | 5 + homeassistant/components/smhi/entity.py | 8 +- homeassistant/components/smhi/icons.json | 27 ++ homeassistant/components/smhi/sensor.py | 139 +++++++ homeassistant/components/smhi/strings.json | 34 ++ homeassistant/components/smhi/weather.py | 1 + .../smhi/snapshots/test_sensor.ambr | 370 ++++++++++++++++++ tests/components/smhi/test_sensor.py | 26 ++ 9 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smhi/icons.json create mode 100644 homeassistant/components/smhi/sensor.py create mode 100644 tests/components/smhi/snapshots/test_sensor.ambr create mode 100644 tests/components/smhi/test_sensor.py diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 1869b333071..085cbdcbbce 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator -PLATFORMS = [Platform.WEATHER] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index 511ba8b38d9..ba7542694df 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -61,3 +61,8 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): daily=_forecast_daily, hourly=_forecast_hourly, ) + + @property + def current(self) -> SMHIForecast: + """Return the current metrics.""" + return self.data.daily[0] diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 89dca3360ca..fb565a7fc51 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,7 +17,6 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): _attr_attribution = "Swedish weather institute (SMHI)" _attr_has_entity_name = True - _attr_name = None def __init__( self, @@ -36,6 +36,12 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): ) self.update_entity_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() + @abstractmethod def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/icons.json b/homeassistant/components/smhi/icons.json new file mode 100644 index 00000000000..5c62b8f03b4 --- /dev/null +++ b/homeassistant/components/smhi/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "thunder": { + "default": "mdi:lightning-bolt" + }, + "total_cloud": { + "default": "mdi:cloud" + }, + "low_cloud": { + "default": "mdi:cloud-arrow-down" + }, + "medium_cloud": { + "default": "mdi:cloud-arrow-right" + }, + "high_cloud": { + "default": "mdi:cloud-arrow-up" + }, + "precipitation_category": { + "default": "mdi:weather-pouring" + }, + "frozen_precipitation": { + "default": "mdi:weather-snowy-rainy" + } + } + } +} diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py new file mode 100644 index 00000000000..bba207c0f09 --- /dev/null +++ b/homeassistant/components/smhi/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for SMHI integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator +from .entity import SmhiWeatherBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_percentage_values(entity: SMHISensor, key: str) -> int | None: + """Return percentage values in correct range.""" + value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment] + if value is not None and 0 <= value <= 100: + return value + if value is not None: + return 0 + return None + + +@dataclass(frozen=True, kw_only=True) +class SMHISensorEntityDescription(SensorEntityDescription): + """Describes SMHI sensor entity.""" + + value_fn: Callable[[SMHISensor], StateType | datetime] + + +SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = ( + SMHISensorEntityDescription( + key="thunder", + translation_key="thunder", + value_fn=lambda entity: get_percentage_values(entity, "thunder"), + native_unit_of_measurement=PERCENTAGE, + ), + SMHISensorEntityDescription( + key="total_cloud", + translation_key="total_cloud", + value_fn=lambda entity: get_percentage_values(entity, "total_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="low_cloud", + translation_key="low_cloud", + value_fn=lambda entity: get_percentage_values(entity, "low_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="medium_cloud", + translation_key="medium_cloud", + value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="high_cloud", + translation_key="high_cloud", + value_fn=lambda entity: get_percentage_values(entity, "high_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="precipitation_category", + translation_key="precipitation_category", + value_fn=lambda entity: str( + get_percentage_values(entity, "precipitation_category") + ), + device_class=SensorDeviceClass.ENUM, + options=["0", "1", "2", "3", "4", "5", "6"], + ), + SMHISensorEntityDescription( + key="frozen_precipitation", + translation_key="frozen_precipitation", + value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"), + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SMHIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SMHI sensor platform.""" + + coordinator = entry.runtime_data + location = entry.data + async_add_entities( + SMHISensor( + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], + coordinator=coordinator, + entity_description=description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class SMHISensor(SmhiWeatherBaseEntity, SensorEntity): + """Representation of a SMHI Sensor.""" + + entity_description: SMHISensorEntityDescription + + def __init__( + self, + latitude: str, + longitude: str, + coordinator: SMHIDataUpdateCoordinator, + entity_description: SMHISensorEntityDescription, + ) -> None: + """Initiate SMHI Sensor.""" + self.entity_description = entity_description + super().__init__( + latitude, + longitude, + coordinator, + ) + self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}" + + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if self.coordinator.data.daily: + self._attr_native_value = self.entity_description.value_fn(self) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 3d2a790e6b6..b6c8f2049fe 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -23,5 +23,39 @@ "error": { "wrong_location": "Location Sweden only" } + }, + "entity": { + "sensor": { + "thunder": { + "name": "Thunder probability" + }, + "total_cloud": { + "name": "Total cloud coverage" + }, + "low_cloud": { + "name": "Low cloud coverage" + }, + "medium_cloud": { + "name": "Medium cloud coverage" + }, + "high_cloud": { + "name": "High cloud coverage" + }, + "precipitation_category": { + "name": "Precipitation category", + "state": { + "0": "No precipitation", + "1": "Snow", + "2": "Snow and rain", + "3": "Rain", + "4": "Drizzle", + "5": "Freezing rain", + "6": "Freezing drizzle" + } + }, + "frozen_precipitation": { + "name": "Frozen precipitation" + } + } } } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5faef04e03d..ccfff7cc2e5 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -111,6 +111,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) + _attr_name = None def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/tests/components/smhi/snapshots/test_sensor.ambr b/tests/components/smhi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8fbdf229494 --- /dev/null +++ b/tests/components/smhi/snapshots/test_sensor.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-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.test_frozen_precipitation', + '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': 'Frozen precipitation', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frozen_precipitation', + 'unique_id': '59.32624, 17.84197-frozen_precipitation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Frozen precipitation', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_frozen_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-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.test_high_cloud_coverage', + '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': 'High cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_cloud', + 'unique_id': '59.32624, 17.84197-high_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test High cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_high_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-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.test_low_cloud_coverage', + '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': 'Low cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_cloud', + 'unique_id': '59.32624, 17.84197-low_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Low cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_low_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-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.test_medium_cloud_coverage', + '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': 'Medium cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'medium_cloud', + 'unique_id': '59.32624, 17.84197-medium_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Medium cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_medium_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_precipitation_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation category', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_category', + 'unique_id': '59.32624, 17.84197-precipitation_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'device_class': 'enum', + 'friendly_name': 'Test Precipitation category', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_precipitation_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-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.test_thunder_probability', + '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': 'Thunder probability', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thunder', + 'unique_id': '59.32624, 17.84197-thunder', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Thunder probability', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_thunder_probability', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-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.test_total_cloud_coverage', + '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': 'Total cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cloud', + 'unique_id': '59.32624, 17.84197-total_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Total cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_total_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/smhi/test_sensor.py b/tests/components/smhi/test_sensor.py new file mode 100644 index 00000000000..a56340af1b5 --- /dev/null +++ b/tests/components/smhi/test_sensor.py @@ -0,0 +1,26 @@ +"""Test for the smhi weather entity.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: EntityRegistry, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the smhi sensors.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) From e28f02d1635f2701cbd002ac08aee247de52244f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:28:18 +0200 Subject: [PATCH 0287/1118] Add initial support for tuya qccdz (#148874) --- homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 4 + .../fixtures/qccdz_ac_charging_control.json | 105 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++++ 4 files changed, 165 insertions(+) create mode 100644 tests/components/tuya/fixtures/qccdz_ac_charging_control.json diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 2cc7970d45a..67f3ba9cb81 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -545,6 +545,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # AC charging + # Not documented + "qccdz": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Unknown product with switch capabilities # Fond in some diffusers, plugs and PIR flood lights # Not documented diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 086a6a3832a..7f08f704fe5 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -113,6 +113,10 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qccdz_ac_charging_control": [ + # https://github.com/home-assistant/core/issues/136207 + Platform.SWITCH, + ], "qxj_temp_humidity_external_probe": [ # https://github.com/home-assistant/core/issues/136472 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/qccdz_ac_charging_control.json b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json new file mode 100644 index 00000000000..1ae5e966de7 --- /dev/null +++ b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json @@ -0,0 +1,105 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1737479380414pasuj4", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf83514d9c14b426f0fz5y", + "name": "AC charging control box", + "category": "qccdz", + "product_id": "7bvgooyjhiua1yyq", + "product_name": "AC charging control box", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-21T17:00:03+00:00", + "create_time": "2025-01-21T17:00:03+00:00", + "update_time": "2025-01-21T17:00:03+00:00", + "function": { + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "work_state": { + "type": "Enum", + "value": { + "range": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault" + ] + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "charge_energy_once": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 1, + "max": 999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "work_state": "charger_free", + "work_mode": "charge_now", + "balance_energy": 0, + "clear_energy": false, + "switch": false, + "charge_energy_once": 1 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1ed4e9fdc1b..dc47486e980 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -869,6 +869,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-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.ac_charging_control_box_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf83514d9c14b426f0fz5yswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC charging control box Switch', + }), + 'context': , + 'entity_id': 'switch.ac_charging_control_box_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 26a9af7371eaf9bce1b8859cddae71178f7b97ed Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 16 Jul 2025 13:26:46 +0200 Subject: [PATCH 0288/1118] Add search functionality to jellyfin (#148822) --- .../components/jellyfin/browse_media.py | 47 +++++++++++++++++++ .../components/jellyfin/media_player.py | 15 +++++- tests/components/jellyfin/conftest.py | 1 + .../components/jellyfin/test_media_player.py | 41 ++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 9eee4bbb363..9dc84971a21 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial from typing import Any from jellyfin_apiclient_python import JellyfinClient @@ -12,6 +13,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -156,6 +158,51 @@ def fetch_items( ] +async def search_items( + hass: HomeAssistant, client: JellyfinClient, user_id: str, query: SearchMediaQuery +) -> list[BrowseMedia]: + """Search items in Jellyfin server.""" + search_result: list[BrowseMedia] = [] + + items: list[dict[str, Any]] = [] + # Search for items based on media filter classes (or all if none specified) + media_types: list[MediaClass] | list[None] = [] + if query.media_filter_classes: + media_types = query.media_filter_classes + else: + media_types = [None] + + for media_type in media_types: + items_dict: dict[str, Any] = await hass.async_add_executor_job( + partial( + client.jellyfin.search_media_items, + term=query.search_query, + media=media_type, + parent_id=query.media_content_id, + ) + ) + items.extend(items_dict.get("Items", [])) + + for item in items: + content_type: str = item["MediaType"] + + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + content_type, MediaClass.DIRECTORY + ), + media_content_id=item["Id"], + media_content_type=content_type, + title=item["Name"], + thumbnail=get_artwork_url(client, item), + can_play=bool(content_type in PLAYABLE_MEDIA_TYPES), + can_expand=item.get("IsFolder", False), + children=None, + ) + search_result.append(response) + + return search_result + + async def get_media_info( hass: HomeAssistant, client: JellyfinClient, diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index b71c0bf93c9..6f3c41d282f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -11,12 +11,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime -from .browse_media import build_item_response, build_root_response +from .browse_media import build_item_response, build_root_response, search_items from .client_wrapper import get_artwork_url from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -196,6 +198,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.SEARCH_MEDIA ) if "Mute" in commands: @@ -274,3 +277,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): media_content_type, media_content_id, ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + result = await search_items( + self.hass, self.coordinator.api_client, self.coordinator.user_id, query + ) + return SearchMedia(result=result) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index c3732714177..71088dea2ea 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -81,6 +81,7 @@ def mock_api() -> MagicMock: jf_api.get_item.side_effect = api_get_item_side_effect jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.search_media_items.return_value = load_json_fixture("user-items.json") return jf_api diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 404fdc801ee..b4506f5a607 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -363,6 +363,47 @@ async def test_browse_media( ) +async def test_search_media( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.jellyfin_device", + "media_content_id": "", + "media_content_type": "", + "search_query": "Fake Item 1", + "media_filter_classes": ["movie"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["result"] == [ + { + "title": "FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "string", + "media_content_id": "FOLDER-UUID", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "not_shown": 0, + "thumbnail": "http://localhost/Items/21af9851-8e39-43a9-9c47-513d3b9e99fc/Images/Primary.jpg", + "children": [], + } + ] + + async def test_new_client_connected( hass: HomeAssistant, init_integration: MockConfigEntry, From 02a11638b38c375823dd407cc5f7b8b68539a1ce Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 16 Jul 2025 05:11:29 -0700 Subject: [PATCH 0289/1118] Add Google AI STT (#147563) --- .../__init__.py | 21 +- .../config_flow.py | 28 ++ .../const.py | 14 +- .../strings.json | 32 ++ .../google_generative_ai_conversation/stt.py | 254 +++++++++++++++ .../conftest.py | 8 + .../snapshots/test_diagnostics.ambr | 8 + .../snapshots/test_init.ambr | 31 ++ .../test_config_flow.py | 211 ++++++++---- .../test_init.py | 61 +++- .../test_stt.py | 303 ++++++++++++++++++ 11 files changed, 897 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/stt.py create mode 100644 tests/components/google_generative_ai_conversation/test_stt.py diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1ff9f355c06..3c1c9cad0b0 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -36,12 +36,14 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, DEFAULT_AI_TASK_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, LOGGER, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -55,6 +57,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( Platform.AI_TASK, Platform.CONVERSATION, + Platform.STT, Platform.TTS, ) @@ -301,7 +304,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_TITLE, @@ -350,8 +353,7 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - # Add AI Task subentry with default options - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) if entry.version == 2 and entry.minor_version == 3: @@ -393,10 +395,10 @@ async def async_migrate_entry( return True -def _add_ai_task_subentry( +def _add_ai_task_and_stt_subentries( hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry ) -> None: - """Add AI Task subentry to the config entry.""" + """Add AI Task and STT subentries to the config entry.""" hass.config_entries.async_add_subentry( entry, ConfigSubentry( @@ -406,3 +408,12 @@ def _add_ai_task_subentry( unique_id=None, ), ) + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_STT_OPTIONS), + subentry_type="stt", + title=DEFAULT_STT_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 7d1429b110e..e760187bc66 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -49,6 +49,8 @@ from .const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_STT_PROMPT, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, @@ -57,6 +59,8 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, @@ -144,6 +148,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -191,6 +201,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Return subentries supported by this integration.""" return { "conversation": LLMSubentryFlowHandler, + "stt": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, "ai_task_data": LLMSubentryFlowHandler, } @@ -228,6 +239,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): options = RECOMMENDED_TTS_OPTIONS.copy() elif self._subentry_type == "ai_task_data": options = RECOMMENDED_AI_TASK_OPTIONS.copy() + elif self._subentry_type == "stt": + options = RECOMMENDED_STT_OPTIONS.copy() else: options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: @@ -304,6 +317,8 @@ async def google_generative_ai_config_option_schema( default_name = DEFAULT_TTS_NAME elif subentry_type == "ai_task_data": default_name = DEFAULT_AI_TASK_NAME + elif subentry_type == "stt": + default_name = DEFAULT_STT_NAME else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { @@ -331,6 +346,17 @@ async def google_generative_ai_config_option_schema( ), } ) + elif subentry_type == "stt": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT) + }, + ): TemplateSelector(), + } + ) schema.update( { @@ -388,6 +414,8 @@ async def google_generative_ai_config_option_schema( if subentry_type == "tts": default_model = RECOMMENDED_TTS_MODEL + elif subentry_type == "stt": + default_model = RECOMMENDED_STT_MODEL else: default_model = RECOMMENDED_CHAT_MODEL diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index b7091fe0222..ba7af5147c5 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -5,18 +5,23 @@ import logging from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm +LOGGER = logging.getLogger(__package__) + DOMAIN = "google_generative_ai_conversation" DEFAULT_TITLE = "Google Generative AI" -LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_STT_NAME = "Google AI STT" DEFAULT_TTS_NAME = "Google AI TTS" DEFAULT_AI_TASK_NAME = "Google AI Task" +CONF_PROMPT = "prompt" +DEFAULT_STT_PROMPT = "Transcribe the attached audio" + CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" +RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 @@ -43,6 +48,11 @@ RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, } +RECOMMENDED_STT_OPTIONS = { + CONF_PROMPT: DEFAULT_STT_PROMPT, + CONF_RECOMMENDED: True, +} + RECOMMENDED_TTS_OPTIONS = { CONF_RECOMMENDED: True, } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 774f41f0279..5af1fe33ce4 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -61,6 +61,38 @@ "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } }, + "stt": { + "initiate_flow": { + "user": "Add Speech-to-Text service", + "reconfigure": "Reconfigure Speech-to-Text service" + }, + "entry_type": "Speech-to-Text", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + }, + "data_description": { + "prompt": "Instruct how the LLM should transcribe the audio." + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, "tts": { "initiate_flow": { "user": "Add Text-to-Speech service", diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py new file mode 100644 index 00000000000..bdf8a2fd7bf --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -0,0 +1,254 @@ +"""Speech to text support for Google Generative AI.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable + +from google.genai.errors import APIError, ClientError +from google.genai.types import Part + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + LOGGER, + RECOMMENDED_STT_MODEL, +) +from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up STT entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "stt": + continue + + async_add_entities( + [GoogleGenerativeAISttEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAISttEntity( + stt.SpeechToTextEntity, GoogleGenerativeAILLMBaseEntity +): + """Google Generative AI speech-to-text entity.""" + + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the STT entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_STT_MODEL) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [ + "af-ZA", + "sq-AL", + "am-ET", + "ar-DZ", + "ar-BH", + "ar-EG", + "ar-IQ", + "ar-IL", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-OM", + "ar-QA", + "ar-SA", + "ar-PS", + "ar-TN", + "ar-AE", + "ar-YE", + "hy-AM", + "az-AZ", + "eu-ES", + "bn-BD", + "bn-IN", + "bs-BA", + "bg-BG", + "my-MM", + "ca-ES", + "zh-CN", + "zh-TW", + "hr-HR", + "cs-CZ", + "da-DK", + "nl-BE", + "nl-NL", + "en-AU", + "en-CA", + "en-GH", + "en-HK", + "en-IN", + "en-IE", + "en-KE", + "en-NZ", + "en-NG", + "en-PK", + "en-PH", + "en-SG", + "en-ZA", + "en-TZ", + "en-GB", + "en-US", + "et-EE", + "fil-PH", + "fi-FI", + "fr-BE", + "fr-CA", + "fr-FR", + "fr-CH", + "gl-ES", + "ka-GE", + "de-AT", + "de-DE", + "de-CH", + "el-GR", + "gu-IN", + "iw-IL", + "hi-IN", + "hu-HU", + "is-IS", + "id-ID", + "it-IT", + "it-CH", + "ja-JP", + "jv-ID", + "kn-IN", + "kk-KZ", + "km-KH", + "ko-KR", + "lo-LA", + "lv-LV", + "lt-LT", + "mk-MK", + "ms-MY", + "ml-IN", + "mr-IN", + "mn-MN", + "ne-NP", + "no-NO", + "fa-IR", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "sr-RS", + "si-LK", + "sk-SK", + "sl-SI", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-SV", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PY", + "es-PE", + "es-PR", + "es-ES", + "es-US", + "es-UY", + "es-VE", + "su-ID", + "sw-KE", + "sw-TZ", + "sv-SE", + "ta-IN", + "ta-MY", + "ta-SG", + "ta-LK", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "ur-PK", + "uz-UZ", + "vi-VN", + "zu-ZA", + ] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + # https://ai.google.dev/gemini-api/docs/audio#supported-formats + return [stt.AudioFormats.WAV, stt.AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bit rates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported sample rates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + # Per https://ai.google.dev/gemini-api/docs/audio + # If the audio source contains multiple channels, Gemini combines those channels into a single channel. + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + audio_data = b"" + async for chunk in stream: + audio_data += chunk + if metadata.format == stt.AudioFormats.WAV: + audio_data = convert_to_wav( + audio_data, + f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}", + ) + + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL), + contents=[ + self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT), + Part.from_bytes( + data=audio_data, + mime_type=f"audio/{metadata.format.value}", + ), + ], + config=self.create_generate_content_config(), + ) + except (APIError, ClientError, ValueError) as err: + LOGGER.error("Error during STT: %s", err) + else: + if response.text: + return stt.SpeechResult( + response.text, + stt.SpeechResultState.SUCCESS, + ) + + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index da5976f46c4..b19482957b2 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -9,6 +9,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry @@ -39,6 +40,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_id": "ulid-conversation", "unique_id": None, }, + { + "data": {}, + "subentry_type": "stt", + "title": DEFAULT_STT_NAME, + "subentry_id": "ulid-stt", + "unique_id": None, + }, { "data": {}, "subentry_type": "tts", diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index d3e27eb99d2..bceb12a9256 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -34,6 +34,14 @@ 'title': 'Google AI Conversation', 'unique_id': None, }), + 'ulid-stt': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-stt', + 'subentry_type': 'stt', + 'title': 'Google AI STT', + 'unique_id': None, + }), 'ulid-tts': dict({ 'data': dict({ }), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index a0d34f49d37..0c57935589b 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -32,6 +32,37 @@ 'sw_version': None, 'via_device_id': None, }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-stt', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI STT', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , 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 bf3e2aedb45..52def1d06bb 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Google Generative AI Conversation config flow.""" +from typing import Any from unittest.mock import Mock, patch import pytest @@ -21,6 +22,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, @@ -28,8 +30,11 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) @@ -64,11 +69,17 @@ def get_models_pager(): ) model_15_pro.name = "models/gemini-1.5-pro-latest" + model_25_flash_tts = Mock( + supported_actions=["generateContent"], + ) + model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts" + async def models_pager(): yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro + yield model_25_flash_tts return models_pager() @@ -129,6 +140,12 @@ async def test_form(hass: HomeAssistant) -> None: "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -157,22 +174,35 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_creating_conversation_subentry( +@pytest.mark.parametrize( + ("subentry_type", "options"), + [ + ("conversation", RECOMMENDED_CONVERSATION_OPTIONS), + ("stt", RECOMMENDED_STT_OPTIONS), + ("tts", RECOMMENDED_TTS_OPTIONS), + ("ai_task_data", RECOMMENDED_AI_TASK_OPTIONS), + ], +) +async def test_creating_subentry( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + options: dict[str, Any], ) -> None: - """Test creating a conversation subentry.""" + """Test creating a subentry.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "conversation"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "set_options" assert not result["errors"] @@ -182,31 +212,117 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, + result["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" + assert result2["data"] == expected_options - processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() - processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 - assert result2["data"] == processed_options + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" -async def test_creating_tts_subentry( +@pytest.mark.parametrize( + ("subentry_type", "recommended_model", "options"), + [ + ( + "conversation", + RECOMMENDED_CHAT_MODEL, + { + CONF_PROMPT: "You are Mario", + CONF_LLM_HASS_API: ["assist"], + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + ), + ( + "stt", + RECOMMENDED_STT_MODEL, + { + CONF_PROMPT: "Transcribe this", + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_STT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "tts", + RECOMMENDED_TTS_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "ai_task_data", + RECOMMENDED_CHAT_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ], +) +async def test_creating_subentry_custom_options( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + recommended_model: str, + options: dict[str, Any], ) -> None: - """Test creating a TTS subentry.""" + """Test creating a subentry with custom options.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "tts"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) @@ -214,75 +330,52 @@ async def test_creating_tts_subentry( assert result["step_id"] == "set_options" assert not result["errors"] - old_subentries = set(mock_config_entry.subentries) - + # Uncheck recommended to show custom options with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + result["data_schema"]({CONF_RECOMMENDED: False}), ) - await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock TTS" - assert result2["data"] == RECOMMENDED_TTS_OPTIONS + # Find the schema key for CONF_CHAT_MODEL and check its default + schema_dict = result2["data_schema"].schema + chat_model_key = next(key for key in schema_dict if key.schema == CONF_CHAT_MODEL) + assert chat_model_key.default() == recommended_model + models_in_selector = [ + opt["value"] for opt in schema_dict[chat_model_key].config["options"] + ] + assert recommended_model in models_in_selector - assert len(mock_config_entry.subentries) == 4 - - new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] - new_subentry = mock_config_entry.subentries[new_subentry_id] - - assert new_subentry.subentry_type == "tts" - assert new_subentry.data == RECOMMENDED_TTS_OPTIONS - assert new_subentry.title == "Mock TTS" - - -async def test_creating_ai_task_subentry( - hass: HomeAssistant, - mock_init_component: None, - mock_config_entry: MockConfigEntry, -) -> None: - """Test creating an AI task subentry.""" + # Submit the form with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "ai_task_data"), - context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM, result - assert result["step_id"] == "set_options" - assert not result["errors"] - - old_subentries = set(mock_config_entry.subentries) - - with patch( - "google.genai.models.AsyncModels.list", - return_value=get_models_pager(), - ): - result2 = await hass.config_entries.subentries.async_configure( - result["flow_id"], - {CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS}, + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + result2["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock AI Task" - assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Mock name" + assert result3["data"] == expected_options - assert len(mock_config_entry.subentries) == 4 + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] new_subentry = mock_config_entry.subentries[new_subentry_id] - assert new_subentry.subentry_type == "ai_task_data" - assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS - assert new_subentry.title == "Mock AI Task" + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" async def test_creating_conversation_subentry_not_loaded( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index e154f9d33c9..fbd52dc9245 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -11,11 +11,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CONVERSATION_OPTIONS, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ( @@ -489,7 +491,7 @@ async def test_migration_from_v1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -516,6 +518,14 @@ async def test_migration_from_v1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -721,7 +731,7 @@ async def test_migration_from_v1_disabled( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -748,6 +758,14 @@ async def test_migration_from_v1_disabled( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME assert not device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.entry_id)} @@ -860,7 +878,7 @@ async def test_migration_from_v1_with_multiple_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -873,6 +891,10 @@ async def test_migration_from_v1_with_multiple_keys( assert subentry.subentry_type == "ai_task_data" assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS assert subentry.title == DEFAULT_AI_TASK_NAME + subentry = list(entry.subentries.values())[3] + assert subentry.subentry_type == "stt" + assert subentry.data == RECOMMENDED_STT_OPTIONS + assert subentry.title == DEFAULT_STT_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -963,7 +985,7 @@ async def test_migration_from_v1_with_same_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -990,6 +1012,14 @@ async def test_migration_from_v1_with_same_keys( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1090,10 +1120,11 @@ async def test_migration_from_v2_1( """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core - 2025.7.0b0-2025.7.0b1 and add AI Task subentry: + 2025.7.0b0-2025.7.0b1 and add AI Task and STT subentries: - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) - Add AI Task subentry (Added in version 2.3) + - Add STT subentry (Added in version 2.3) """ # Create a v2.1 config entry with 2 subentries, devices and entities options = { @@ -1184,7 +1215,7 @@ async def test_migration_from_v2_1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -1211,6 +1242,14 @@ async def test_migration_from_v2_1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1320,8 +1359,8 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.minor_version == 4 - # Check we now have conversation, tts and ai_task_data subentries - assert len(entry.subentries) == 3 + # Check we now have conversation, tts, stt, and ai_task_data subentries + assert len(entry.subentries) == 4 subentries = { subentry.subentry_type: subentry for subentry in entry.subentries.values() @@ -1336,6 +1375,12 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + # Find and verify the stt subentry + ai_task_subentry = subentries["stt"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_STT_NAME + assert ai_task_subentry.data == RECOMMENDED_STT_OPTIONS + # Verify conversation subentry is still there and unchanged conversation_subentry = subentries["conversation"] assert conversation_subentry is not None diff --git a/tests/components/google_generative_ai_conversation/test_stt.py b/tests/components/google_generative_ai_conversation/test_stt.py new file mode 100644 index 00000000000..90c58ebba16 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_stt.py @@ -0,0 +1,303 @@ +"""Tests for the Google Generative AI Conversation STT entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Generator +from unittest.mock import AsyncMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import stt +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + DOMAIN, + RECOMMENDED_STT_MODEL, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST + +from tests.common import MockConfigEntry + +TEST_CHAT_MODEL = "models/gemini-2.5-flash" +TEST_PROMPT = "Please transcribe the audio." + + +async def _async_get_audio_stream(data: bytes) -> AsyncIterable[bytes]: + """Yield the audio data.""" + yield data + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai.Client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "This is a test transcription."}], + "role": "model", + } + } + ] + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client.return_value + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + + sub_entry = ConfigSubentry( + data={ + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + CONF_PROMPT: TEST_PROMPT, + }, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_entity_properties(hass: HomeAssistant) -> None: + """Test STT entity properties.""" + entity: stt.SpeechToTextEntity = hass.data[stt.DOMAIN].get_entity( + "stt.google_ai_stt" + ) + assert entity is not None + assert isinstance(entity.supported_languages, list) + assert stt.AudioFormats.WAV in entity.supported_formats + assert stt.AudioFormats.OGG in entity.supported_formats + assert stt.AudioCodecs.PCM in entity.supported_codecs + assert stt.AudioCodecs.OPUS in entity.supported_codecs + assert stt.AudioBitRates.BITRATE_16 in entity.supported_bit_rates + assert stt.AudioSampleRates.SAMPLERATE_16000 in entity.supported_sample_rates + assert stt.AudioChannels.CHANNEL_MONO in entity.supported_channels + + +@pytest.mark.parametrize( + ("audio_format", "call_convert_to_wav"), + [ + (stt.AudioFormats.WAV, True), + (stt.AudioFormats.OGG, False), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_success( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + audio_format: stt.AudioFormats, + call_convert_to_wav: bool, +) -> None: + """Test STT processing audio stream successfully.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=audio_format, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + with patch( + "homeassistant.components.google_generative_ai_conversation.stt.convert_to_wav", + return_value=b"converted_wav_bytes", + ) as mock_convert_to_wav: + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "This is a test transcription." + + if call_convert_to_wav: + mock_convert_to_wav.assert_called_once_with( + b"test_audio_bytes", "audio/L16;rate=16000" + ) + else: + mock_convert_to_wav.assert_not_called() + + mock_genai_client.aio.models.generate_content.assert_called_once() + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == TEST_CHAT_MODEL + + contents = call_args.kwargs["contents"] + assert contents[0] == TEST_PROMPT + assert isinstance(contents[1], types.Part) + assert contents[1].inline_data.mime_type == f"audio/{audio_format.value}" + if call_convert_to_wav: + assert contents[1].inline_data.data == b"converted_wav_bytes" + else: + assert contents[1].inline_data.data == b"test_audio_bytes" + + +@pytest.mark.parametrize( + "side_effect", + [ + API_ERROR_500, + CLIENT_ERROR_BAD_REQUEST, + ValueError("Test value error"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_api_error( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test STT processing audio stream with API errors.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.side_effect = side_effect + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_empty_response( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test STT processing with an empty response from the API.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.return_value = ( + types.GenerateContentResponse(candidates=[]) + ) + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_prompt( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default prompt is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no prompt + sub_entry = ConfigSubentry( + data={CONF_CHAT_MODEL: TEST_CHAT_MODEL}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + contents = call_args.kwargs["contents"] + assert contents[0] == DEFAULT_STT_PROMPT + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_model( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default model is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no model + sub_entry = ConfigSubentry( + data={CONF_PROMPT: TEST_PROMPT}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == RECOMMENDED_STT_MODEL From 62e3802ff28d1291c08041436fb38646e7d140ec Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Jul 2025 14:22:42 +0200 Subject: [PATCH 0290/1118] Deprecate MediaPlayerState.STANDBY (#148151) Co-authored-by: Franck Nijhof --- homeassistant/components/media_player/__init__.py | 3 ++- homeassistant/components/media_player/const.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d0c6bcabfcf..b2cb7d76e8f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1041,7 +1041,8 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.STANDBY, + # Not comparing to MediaPlayerState.STANDBY to avoid deprecation warning + "standby", }: await self.async_turn_on() else: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 8d85d7cd106..f842ccccb65 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -50,7 +51,13 @@ ATTR_SOUND_MODE_LIST = "sound_mode_list" DOMAIN = "media_player" -class MediaPlayerState(StrEnum): +class MediaPlayerState( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "STANDBY": ("MediaPlayerState.OFF or MediaPlayerState.IDLE", "2026.8.0"), + }, +): """State of media player entities.""" OFF = "off" From 0d79f7db51f806ab27042723667c8b827a5ff9ba Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:43:55 +0200 Subject: [PATCH 0291/1118] Update mypy-dev to 1.18.0a2 (#148880) --- homeassistant/components/androidtv_remote/helpers.py | 2 +- homeassistant/components/bthome/coordinator.py | 2 +- homeassistant/components/bthome/device_trigger.py | 2 +- .../components/islamic_prayer_times/coordinator.py | 6 +++--- homeassistant/components/mikrotik/coordinator.py | 4 ++-- homeassistant/components/shelly/coordinator.py | 2 +- homeassistant/components/shelly/utils.py | 2 +- homeassistant/components/squeezebox/media_player.py | 2 +- homeassistant/components/transmission/coordinator.py | 4 ++-- homeassistant/components/unifiprotect/data.py | 4 ++-- homeassistant/components/xiaomi_ble/coordinator.py | 2 +- requirements_test.txt | 2 +- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index a67d5839ee6..9052a414393 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool: """Get value of enable_ime option or its default value.""" - return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) + return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return] diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 2ef29541f40..6ab88c48c46 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -45,7 +45,7 @@ class BTHomePassiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class BTHomePassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 6d194714c64..b9e01051419 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -70,7 +70,7 @@ def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[ bthome_config_entry = next( entry for entry in config_entries if entry and entry.domain == DOMAIN ) - return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) # type: ignore[no-any-return] def get_event_types_by_event_class(event_class: str) -> set[str]: diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index a6cd3fb151e..8bd7e5904b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -54,7 +54,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def calc_method(self) -> str: """Return the calculation method.""" - return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) # type: ignore[no-any-return] @property def lat_adj_method(self) -> str: @@ -68,12 +68,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def midnight_mode(self) -> str: """Return the midnight mode.""" - return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) # type: ignore[no-any-return] @property def school(self) -> str: """Return the school.""" - return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) # type: ignore[no-any-return] def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: """Fetch prayer times for the specified date.""" diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index c68b13eeca8..a94d3b4b64e 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -83,12 +83,12 @@ class MikrotikData: @property def arp_enabled(self) -> bool: """Return arp_ping option setting.""" - return self.config_entry.options.get(CONF_ARP_PING, False) + return self.config_entry.options.get(CONF_ARP_PING, False) # type: ignore[no-any-return] @property def force_dhcp(self) -> bool: """Return force_dhcp option setting.""" - return self.config_entry.options.get(CONF_FORCE_DHCP, False) + return self.config_entry.options.get(CONF_FORCE_DHCP, False) # type: ignore[no-any-return] def get_info(self, param: str) -> str: """Return device model name.""" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index fa434588b34..9291d7aa70f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -163,7 +163,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) # type: ignore[no-any-return] def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 953fcbace06..1af365debfb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -451,7 +451,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get(CONF_GEN, 1) + return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return] def get_rpc_key_instances( diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index f37faa4e115..dc426d76588 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -287,7 +287,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( + return self.coordinator.config_entry.options.get( # type: ignore[no-any-return] CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT ) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index afe2660e711..458f719e5f2 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -60,12 +60,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): @property def limit(self) -> int: """Return limit.""" - return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) + return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) # type: ignore[no-any-return] @property def order(self) -> str: """Return order.""" - return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) + return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] async def _async_update_data(self) -> SessionStats: """Update transmission data.""" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index baecc7f8323..1c03febe74b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -93,12 +93,12 @@ class ProtectData: @property def disable_stream(self) -> bool: """Check if RTSP is disabled.""" - return self._entry.options.get(CONF_DISABLE_RTSP, False) + return self._entry.options.get(CONF_DISABLE_RTSP, False) # type: ignore[no-any-return] @property def max_events(self) -> int: """Max number of events to load at once.""" - return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) # type: ignore[no-any-return] @callback def async_subscribe_adopt( diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 69fc427013a..a07b7fde3b1 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -67,7 +67,7 @@ class XiaomiActiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class XiaomiPassiveBluetoothDataProcessor[_T]( diff --git a/requirements_test.txt b/requirements_test.txt index 386e380911a..b758a7b517a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.17.0a4 +mypy-dev==1.18.0a2 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From 3e465da89208633c8b238ad9a89cdac2b763797e Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 16 Jul 2025 19:52:53 +0700 Subject: [PATCH 0292/1118] Add Code Interpreter tool for OpenAI Conversation (#148383) --- .../openai_conversation/config_flow.py | 21 ++--- .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 19 +++- .../openai_conversation/strings.json | 2 + .../openai_conversation/__init__.py | 89 +++++++++++++++++++ .../openai_conversation/conftest.py | 11 ++- .../openai_conversation/test_config_flow.py | 38 +++++++- .../openai_conversation/test_conversation.py | 48 ++++++++++ 8 files changed, 206 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ce6872c7c20..aa1c967ca8f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -42,6 +42,7 @@ from homeassistant.helpers.typing import VolDictType from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -60,6 +61,7 @@ from .const import ( DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CODE_INTERPRETER, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -312,7 +314,12 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): options = self.options errors: dict[str, str] = {} - step_schema: VolDictType = {} + step_schema: VolDictType = { + vol.Optional( + CONF_CODE_INTERPRETER, + default=RECOMMENDED_CODE_INTERPRETER, + ): bool, + } model = options[CONF_CHAT_MODEL] @@ -375,18 +382,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): ) } - if not step_schema: - if self._is_new: - return self.async_create_entry( - title=options.pop(CONF_NAME), - data=options, - ) - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=options, - ) - if user_input is not None: if user_input.get(CONF_WEB_SEARCH): if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index a15f71118c0..cacef6fcff9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -13,6 +13,7 @@ DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" +CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" @@ -27,6 +28,7 @@ CONF_WEB_SEARCH_CITY = "city" CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" +RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7679bef83f1..93713c78d9c 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -38,6 +38,10 @@ from openai.types.responses import ( WebSearchToolParam, ) from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.tool_param import ( + CodeInterpreter, + CodeInterpreterContainerCodeInterpreterToolAuto, +) from openai.types.responses.web_search_tool_param import UserLocation import voluptuous as vol from voluptuous_openapi import convert @@ -52,6 +56,7 @@ from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_REASONING_EFFORT, CONF_TEMPERATURE, @@ -292,7 +297,7 @@ class OpenAIBaseLLMEntity(Entity): """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[ToolParam] | None = None + tools: list[ToolParam] = [] if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -314,10 +319,18 @@ class OpenAIBaseLLMEntity(Entity): country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), ) - if tools is None: - tools = [] tools.append(web_search) + if options.get(CONF_CODE_INTERPRETER): + tools.append( + CodeInterpreter( + type="code_interpreter", + container=CodeInterpreterContainerCodeInterpreterToolAuto( + type="auto" + ), + ) + ) + model_args = { "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), "input": [], diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 5011fc9cf99..fef955b4fa9 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -48,12 +48,14 @@ "model": { "title": "Model-specific options", "data": { + "code_interpreter": "Enable code interpreter tool", "reasoning_effort": "Reasoning effort", "web_search": "Enable web search", "search_context_size": "Search context size", "user_location": "Include home location" }, "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "web_search": "Allow the model to search the web for the latest information before generating a response", "search_context_size": "High level guidance for the amount of context window space to use for the search", diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index 11dc978250a..c10c23df237 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1,6 +1,12 @@ """Tests for the OpenAI Conversation integration.""" from openai.types.responses import ( + ResponseCodeInterpreterCallCodeDeltaEvent, + ResponseCodeInterpreterCallCodeDoneEvent, + ResponseCodeInterpreterCallCompletedEvent, + ResponseCodeInterpreterCallInProgressEvent, + ResponseCodeInterpreterCallInterpretingEvent, + ResponseCodeInterpreterToolCall, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseFunctionCallArgumentsDeltaEvent, @@ -239,3 +245,86 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve type="response.output_item.done", ), ] + + +def create_code_interpreter_item( + id: str, code: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(code, str): + code = [code] + + container_id = "cntr_A" + events = [ + ResponseOutputItemAddedEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code="", + container_id=container_id, + outputs=None, + type="code_interpreter_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseCodeInterpreterCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.in_progress", + ), + ] + + events.extend( + ResponseCodeInterpreterCallCodeDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call_code.delta", + ) + for delta in code + ) + + code = "".join(code) + + events.extend( + [ + ResponseCodeInterpreterCallCodeDoneEvent( + item_id=id, + output_index=output_index, + code=code, + sequence_number=0, + type="response.code_interpreter_call_code.done", + ), + ResponseCodeInterpreterCallInterpretingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.interpreting", + ), + ResponseCodeInterpreterCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=None, + status="completed", + type="code_interpreter_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 84c907a7c2e..b58e6c31f38 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -156,9 +156,10 @@ def mock_create_stream() -> Generator[AsyncMock]: ) yield ResponseInProgressEvent( response=response, - sequence_number=0, + sequence_number=1, type="response.in_progress", ) + sequence_number = 2 response.status = "completed" for value in events: @@ -173,6 +174,8 @@ def mock_create_stream() -> Generator[AsyncMock]: response.error = value break + value.sequence_number = sequence_number + sequence_number += 1 yield value if isinstance(value, ResponseErrorEvent): @@ -181,19 +184,19 @@ def mock_create_stream() -> Generator[AsyncMock]: if response.status == "incomplete": yield ResponseIncompleteEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.incomplete", ) elif response.status == "failed": yield ResponseFailedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.failed", ) else: yield ResponseCompletedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.completed", ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 0ccbc39160a..6d8fb143f88 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.openai_conversation.config_flow import ( ) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -311,6 +312,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), { @@ -321,6 +323,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: 10000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), ( # options for web search without user location @@ -343,6 +346,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -355,6 +359,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), # Test that current options are showed as suggested values @@ -373,6 +378,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -389,6 +395,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), { @@ -401,6 +408,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), ( # Case 2: reasoning model @@ -424,7 +432,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, - {CONF_REASONING_EFFORT: "high"}, + {CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: False}, ), { CONF_RECOMMENDED: False, @@ -434,6 +442,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: False, }, ), # Test that old options are removed after reconfiguration @@ -445,6 +454,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_CODE_INTERPRETER: True, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -476,6 +486,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ( { @@ -504,6 +515,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -518,6 +530,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), { @@ -528,6 +541,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), ( # Case 4: reasoning to web search @@ -540,6 +554,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ( { @@ -556,6 +571,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -568,6 +584,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), ], @@ -718,6 +735,7 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: False, } @@ -817,12 +835,24 @@ async def test_creating_ai_task_subentry_advanced( }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Advanced AI Task" - assert result3.get("data") == { + assert result3.get("type") is FlowResultType.FORM + assert result3.get("step_id") == "model" + + # Configure model settings + result4 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CODE_INTERPRETER: False, + }, + ) + + assert result4.get("type") is FlowResultType.CREATE_ENTRY + assert result4.get("title") == "Advanced AI Task" + assert result4.get("data") == { CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "gpt-4o", CONF_MAX_TOKENS: 200, CONF_TEMPERATURE: 0.5, CONF_TOP_P: 0.9, + CONF_CODE_INTERPRETER: False, } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 39cd129e1ba..dafcba7bfeb 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -16,6 +16,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.openai_conversation.const import ( + CONF_CODE_INTERPRETER, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -30,6 +31,7 @@ from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from . import ( + create_code_interpreter_item, create_function_tool_call_item, create_message_item, create_reasoning_item, @@ -485,3 +487,49 @@ async def test_web_search( ] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + + +async def test_code_interpreter( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test code_interpreter tool.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + CONF_CODE_INTERPRETER: True, + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758." + mock_create_stream.return_value = [ + ( + *create_code_interpreter_item( + id="ci_A", + code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], + output_index=0, + ), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "Please use the python tool to calculate square root of 55555", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech From 412035b9705ab1df65808c984ebb1ad12156ec6b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 15:07:53 +0200 Subject: [PATCH 0293/1118] Add devices to OpenRouter (#148888) --- homeassistant/components/open_router/conversation.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 48720e7c829..48fb1ec44cb 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenRouterConfigEntry @@ -61,13 +62,20 @@ def _convert_content_to_chat_message( class OpenRouterConversationEntity(conversation.ConversationEntity): """OpenRouter conversation agent.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry self.subentry = subentry self.model = subentry.data[CONF_MODEL] - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=DeviceEntryType.SERVICE, + ) @property def supported_languages(self) -> list[str] | Literal["*"]: From 840e0d1388f9ca66dc123c5a62dcb36dc0ce7e67 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:19:22 +0200 Subject: [PATCH 0294/1118] Clean up ModuleWrapper from loader (#148488) --- homeassistant/loader.py | 79 ----------------------------------------- 1 file changed, 79 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1e338be0a0f..07c4a934573 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -10,7 +10,6 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass -import functools as ft import importlib import logging import os @@ -1650,77 +1649,6 @@ class CircularDependency(LoaderError): self.args[1].insert(0, domain) -def _load_file( - hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ComponentProtocol | None: - """Try to load specified file. - - Looks in config dir first, then built-in components. - Only returns it if also found to be valid. - Async friendly. - """ - cache = hass.data[DATA_COMPONENTS] - if module := cache.get(comp_or_platform): - return cast(ComponentProtocol, module) - - for path in (f"{base}.{comp_or_platform}" for base in base_paths): - try: - module = importlib.import_module(path) - - # In Python 3 you can import files from directories that do not - # contain the file __init__.py. A directory is a valid module if - # it contains a file with the .py extension. In this case Python - # will succeed in importing the directory as a module and call it - # a namespace. We do not care about namespaces. - # This prevents that when only - # custom_components/switch/some_platform.py exists, - # the import custom_components.switch would succeed. - # __file__ was unset for namespaces before Python 3.7 - if getattr(module, "__file__", None) is None: - continue - - cache[comp_or_platform] = module - - return cast(ComponentProtocol, module) - - except ImportError as err: - # This error happens if for example custom_components/switch - # exists and we try to load switch.demo. - # Ignore errors for custom_components, custom_components.switch - # and custom_components.switch.demo. - white_listed_errors = [] - parts = [] - for part in path.split("."): - parts.append(part) - white_listed_errors.append(f"No module named '{'.'.join(parts)}'") - - if str(err) not in white_listed_errors: - _LOGGER.exception( - "Error loading %s. Make sure all dependencies are installed", path - ) - - return None - - -class ModuleWrapper: - """Class to wrap a Python module and auto fill in hass argument.""" - - def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: - """Initialize the module wrapper.""" - self._hass = hass - self._module = module - - def __getattr__(self, attr: str) -> Any: - """Fetch an attribute.""" - value = getattr(self._module, attr) - - if hasattr(value, "__bind_hass"): - value = ft.partial(value, self._hass) - - setattr(self, attr, value) - return value - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. @@ -1744,13 +1672,6 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path_importer_cache.pop(hass.config.config_dir, None) -def _lookup_path(hass: HomeAssistant) -> list[str]: - """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode or hass.config.safe_mode: - return [PACKAGE_BUILTIN] - return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] - - def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] From b68de0af88012c4f937fd10c696d6da27693f892 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:48:39 +0200 Subject: [PATCH 0295/1118] Change deprecated media_player state standby to off in PlayStation Network (#148885) --- homeassistant/components/playstation_network/media_player.py | 2 -- .../playstation_network/snapshots/test_media_player.ambr | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 0a9b8fe6162..bdbc2a5ddd4 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -125,8 +125,6 @@ class PsnMediaPlayerEntity( if session.title_id is not None else MediaPlayerState.ON ) - if session.status == "standby": - return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index 69024c2326f..891509b351c 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -39,9 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', - 'entity_picture_local': None, 'friendly_name': 'PlayStation Vita', - 'media_content_type': , 'supported_features': , }), 'context': , @@ -49,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'standby', + 'state': 'off', }) # --- # name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] From 3449863eee850a62fe5f4cc2e8b8ec67cbc5800f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 15:49:02 +0200 Subject: [PATCH 0296/1118] Bump `gios` to version 6.1.2 (#148884) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 1782320a357..8c6765ece89 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.1"] + "requirements": ["gios==6.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f89f00451de..09800ef9e94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f3345ae688..8dfe7a8edac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.glances glances-api==0.8.0 From 1734b316d517e999702b87e5fbbaf666cb9a2aae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 16 Jul 2025 16:16:01 +0200 Subject: [PATCH 0297/1118] Return intent response from LLM chat log if available (#148522) --- .../components/anthropic/conversation.py | 12 +---- .../components/conversation/__init__.py | 2 + .../components/conversation/chat_log.py | 2 + homeassistant/components/conversation/util.py | 47 +++++++++++++++++++ .../conversation.py | 20 ++------ .../components/ollama/conversation.py | 14 +----- .../components/open_router/conversation.py | 10 +--- .../openai_conversation/conversation.py | 10 +--- homeassistant/helpers/llm.py | 21 +++++++-- tests/components/conversation/conftest.py | 26 +++++++++- .../components/conversation/test_chat_log.py | 22 --------- tests/components/conversation/test_util.py | 39 +++++++++++++++ .../test_conversation.py | 2 +- 13 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 homeassistant/components/conversation/util.py create mode 100644 tests/components/conversation/test_util.py diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 12c7917a30a..4eb40974b7a 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -72,13 +71,4 @@ class AnthropicConversationEntity( await self._async_handle_chat_log(chat_log) - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index ec866604205..3435a7d2ed4 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -61,6 +61,7 @@ from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append +from .util import async_get_result_from_chat_log __all__ = [ "DOMAIN", @@ -83,6 +84,7 @@ __all__ = [ "async_converse", "async_get_agent_info", "async_get_chat_log", + "async_get_result_from_chat_log", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8d739b6267d..648a89e47f1 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -196,6 +196,7 @@ class ChatLog: extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + llm_input_provided_index = 0 @property def continue_conversation(self) -> bool: @@ -496,6 +497,7 @@ class ChatLog: prompt = "\n".join(prompt_parts) + self.llm_input_provided_index = len(self.content) self.llm_api = llm_api self.extra_system_prompt = extra_system_prompt self.content[0] = SystemContent(content=prompt) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000..04a5a420279 --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,47 @@ +"""Utility functions for conversation integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm + +from .chat_log import AssistantContent, ChatLog, ToolResultContent +from .models import ConversationInput, ConversationResult + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_result_from_chat_log( + user_input: ConversationInput, chat_log: ChatLog +) -> ConversationResult: + """Get the result from the chat log.""" + tool_results = [ + content.tool_result + for content in chat_log.content[chat_log.llm_input_provided_index :] + if isinstance(content, ToolResultContent) + and isinstance(content.tool_result, llm.IntentResponseDict) + ] + + if tool_results: + intent_response = tool_results[-1].original + else: + intent_response = intent.IntentResponse(language=user_input.language) + + if not isinstance((last_content := chat_log.content[-1]), AssistantContent): + _LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + last_content, + ) + raise HomeAssistantError("Unable to get response") + + intent_response.async_set_speech(last_content.content or "") + + return ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 3525fba3af5..d804073bfb4 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -8,12 +8,10 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_PROMPT, DOMAIN, LOGGER -from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity +from .const import CONF_PROMPT, DOMAIN +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -84,16 +82,4 @@ class GoogleGenerativeAIConversationEntity( await self._async_handle_chat_log(chat_log) - response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", - chat_log.content[-1], - ) - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index e0b64702cb4..cba8559e826 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -8,7 +8,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry @@ -84,15 +83,4 @@ class OllamaConversationEntity( await self._async_handle_chat_log(chat_log) - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 48fb1ec44cb..efc98835982 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,11 +130,4 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): ) ) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 25e89577ef3..803825c2810 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -84,11 +83,4 @@ class OpenAIConversationEntity( await self._async_handle_chat_log(chat_log) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 784288375e9..1ff6b188214 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -315,10 +315,23 @@ class IntentTool(Tool): assistant=llm_context.assistant, device_id=llm_context.device_id, ) - response = intent_response.as_dict() - del response["language"] - del response["card"] - return response + return IntentResponseDict(intent_response) + + +class IntentResponseDict(dict): + """Dictionary to represent an intent response resulting from a tool call.""" + + def __init__(self, intent_response: Any) -> None: + """Initialize the dictionary.""" + if not isinstance(intent_response, intent.IntentResponse): + super().__init__(intent_response) + return + + result = intent_response.as_dict() + del result["language"] + del result["card"] + super().__init__(result) + self.original = intent_response class NamespacedTool(Tool): diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 6575ab2ac98..8dfe879ee2b 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -1,13 +1,14 @@ """Conversation test helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest from homeassistant.components import conversation from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from . import MockAgent @@ -15,6 +16,14 @@ from . import MockAgent from tests.common import MockConfigEntry +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.fixture def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: """Mock agent that supports all languages.""" @@ -25,6 +34,19 @@ def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: return agent +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInput: + """Return a conversation input instance.""" + return conversation.ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + @pytest.fixture(autouse=True) def mock_shopping_list_io(): """Stub out the persistence.""" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 0e2a384f1da..811c045dd70 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,5 @@ """Test the conversation session.""" -from collections.abc import Generator from dataclasses import asdict from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch @@ -26,27 +25,6 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -@pytest.fixture -def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: - """Return a conversation input instance.""" - return ConversationInput( - text="Hello", - context=Context(), - conversation_id=None, - agent_id="mock-agent-id", - device_id=None, - language="en", - ) - - -@pytest.fixture -def mock_ulid() -> Generator[Mock]: - """Mock the ulid library.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" - yield mock_ulid_now - - async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py new file mode 100644 index 00000000000..196de4ad2fb --- /dev/null +++ b/tests/components/conversation/test_util.py @@ -0,0 +1,39 @@ +"""Tests for conversation utility functions.""" + +from homeassistant.components import conversation +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, intent, llm + + +async def test_async_get_result_from_chat_log( + hass: HomeAssistant, + mock_conversation_input: conversation.ConversationInput, +) -> None: + """Test getting result from chat log.""" + intent_response = intent.IntentResponse(language="en") + with ( + chat_session.async_get_chat_session(hass) as session, + conversation.async_get_chat_log( + hass, session, mock_conversation_input + ) as chat_log, + ): + chat_log.content.extend( + [ + conversation.ToolResultContent( + agent_id="mock-agent-id", + tool_call_id="mock-tool-call-id", + tool_name="mock-tool-name", + tool_result=llm.IntentResponseDict(intent_response), + ), + conversation.AssistantContent( + agent_id="mock-agent-id", + content="This is a response.", + ), + ] + ) + result = conversation.async_get_result_from_chat_log( + mock_conversation_input, chat_log + ) + # Original intent response is returned with speech set + assert result.response is intent_response + assert result.response.speech["plain"]["speech"] == "This is a response." diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ff9694257f9..90f496b4b5b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -359,7 +359,7 @@ 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"] == ( - ERROR_GETTING_RESPONSE + "Unable to get response" ) From aab6cd665f4dd9515e0c7187783c132802419415 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Jul 2025 17:06:35 +0200 Subject: [PATCH 0298/1118] Fix flaky notify group test (#148895) --- tests/components/group/test_notify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index e3a01c05eca..49ad71f5b6b 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -199,7 +199,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No }, }, ), - ] + ], + any_order=True, ) From e2340314c69d754da3f0c1da822c3506abfa4a19 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:40:35 +0200 Subject: [PATCH 0299/1118] Do not allow filters for services with no target in hassfest (#148869) --- script/hassfest/services.py | 153 +++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 70 deletions(-) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 70f0a63ca76..84d3aaefa88 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -43,104 +43,117 @@ def unique_field_validator(fields: Any) -> Any: return fields -CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( - { - vol.Optional("example"): exists, - vol.Optional("default"): exists, - vol.Optional("required"): bool, - vol.Optional("advanced"): bool, - vol.Optional(CONF_SELECTOR): selector.validate_selector, - vol.Optional("filter"): { - vol.Exclusive("attribute", "field_filter"): { - vol.Required(str): [vol.All(str, service.validate_attribute_option)], - }, - vol.Exclusive("supported_features", "field_filter"): [ - vol.All(str, service.validate_supported_feature) - ], +CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT = { + vol.Optional("description"): str, + vol.Optional("name"): str, +} + + +CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT = { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional("advanced"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, +} + +FIELD_FILTER_SCHEMA_DICT = { + vol.Optional("filter"): { + vol.Exclusive("attribute", "field_filter"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], }, + vol.Exclusive("supported_features", "field_filter"): [ + vol.All(str, service.validate_supported_feature) + ], } -) +} -CORE_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { + +def _field_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the field schema.""" + schema_dict = CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT.copy() + + # Filters are only allowed for targeted services because they rely on the presence + # of a `target` field to determine the scope of the service call. Non-targeted + # services do not have a `target` field, making filters inapplicable. + if targeted: + schema_dict |= FIELD_FILTER_SCHEMA_DICT + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) + + +def _section_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the section schema.""" + schema_dict = { vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + vol.Required("fields"): vol.Schema( + { + str: _field_schema(targeted, custom), + } + ), } -) -CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - } -) + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT -CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + return vol.Schema(schema_dict) + + +def _service_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the service schema.""" + schema_dict = { + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + _field_schema(targeted, custom), + _section_schema(targeted, custom), + ), + } + ), + unique_field_validator, + ) } -) + + if targeted: + schema_dict[vol.Required("target")] = vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ) + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CORE_INTEGRATION_FIELD_SCHEMA, - CORE_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=False), + _service_schema(targeted=False, custom=False), None, ) CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CUSTOM_INTEGRATION_FIELD_SCHEMA, - CUSTOM_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=True), + _service_schema(targeted=False, custom=True), None, ) + CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, service.starts_with_dot)): object, cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA, } ) + CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} ) + VALIDATE_AS_CUSTOM_INTEGRATION = { # Adding translations would be a breaking change "foursquare", From a5f0f6c8b9b07eb641701540d11594b527f7bc6c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 18:23:38 +0200 Subject: [PATCH 0300/1118] Add prompt as constant and common translation key (#148896) --- homeassistant/components/anthropic/strings.json | 2 +- .../components/google_generative_ai_conversation/strings.json | 4 ++-- homeassistant/components/ollama/strings.json | 4 ++-- homeassistant/components/openai_conversation/strings.json | 2 +- homeassistant/const.py | 1 + homeassistant/strings.json | 1 + 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 098b4d5fa74..983260a3c95 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -29,7 +29,7 @@ "set_options": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 5af1fe33ce4..11e7c75c8ba 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -34,7 +34,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "Recommended model settings", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", @@ -72,7 +72,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4261b2286bf..87d2048a966 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -28,7 +28,7 @@ "data": { "model": "Model", "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "max_history": "Max history messages", "num_ctx": "Context window size", @@ -67,7 +67,7 @@ "data": { "model": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::model%]", "name": "[%key:common::config_flow::data::name%]", - "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::prompt%]", + "prompt": "[%key:common::config_flow::data::prompt%]", "max_history": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::max_history%]", "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::num_ctx%]", "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::keep_alive%]", diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index fef955b4fa9..4446eff2c9e 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -28,7 +28,7 @@ "init": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b4f16c316f..2daa6d91db2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -245,6 +245,7 @@ CONF_PLATFORM: Final = "platform" CONF_PORT: Final = "port" CONF_PREFIX: Final = "prefix" CONF_PROFILE_NAME: Final = "profile_name" +CONF_PROMPT: Final = "prompt" CONF_PROTOCOL: Final = "protocol" CONF_PROXY_SSL: Final = "proxy_ssl" CONF_QUOTE: Final = "quote" diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 80ced039e46..8e232498177 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -65,6 +65,7 @@ "path": "Path", "pin": "PIN code", "port": "Port", + "prompt": "Instructions", "ssl": "Uses an SSL certificate", "url": "URL", "usb_path": "USB device path", From fca05f6bcf4d15c0d531fce9d0d525b51f4d30cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:34:28 +0200 Subject: [PATCH 0301/1118] Add snapshot tests for tuya dj category (#148897) --- tests/components/tuya/__init__.py | 4 + tests/components/tuya/conftest.py | 3 + .../tuya/fixtures/dj_smart_light_bulb.json | 458 ++++++++++++++++++ .../components/tuya/snapshots/test_light.ambr | 71 +++ 4 files changed, 536 insertions(+) create mode 100644 tests/components/tuya/fixtures/dj_smart_light_bulb.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7f08f704fe5..c3d6c31924e 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -74,6 +74,10 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "dj_smart_light_bulb": [ + # https://github.com/home-assistant/core/pull/126242 + Platform.LIGHT + ], "dlq_earu_electric_eawcpt": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 3d89e1d6f92..cac9359a8d3 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -180,4 +180,7 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev for key, value in details["status_range"].items() } device.status = details["status"] + for key, value in device.status.items(): + if device.status_range[key].type == "Json": + device.status[key] = json_dumps(value) return device diff --git a/tests/components/tuya/fixtures/dj_smart_light_bulb.json b/tests/components/tuya/fixtures/dj_smart_light_bulb.json new file mode 100644 index 00000000000..49854adc889 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_smart_light_bulb.json @@ -0,0 +1,458 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "REDACTED", + "name": "Garage light", + "category": "dj", + "product_id": "mki13ie507rlry4r", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-15T19:53:11+00:00", + "create_time": "2024-06-15T19:53:11+00:00", + "update_time": "2024-06-15T19:53:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 546, + "colour_data_v2": { + "h": 243, + "s": 860, + "v": 541 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 5b0afb289ac..c691aae2cc1 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,6 +56,77 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-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.garage_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.REDACTEDswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 138, + 'color_mode': , + 'friendly_name': 'Garage light', + 'hs_color': tuple( + 243.0, + 86.0, + ), + 'rgb_color': tuple( + 47, + 36, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.148, + 0.055, + ), + }), + 'context': , + 'entity_id': 'light.garage_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 58bb2fa327c413c44a68b3098bddbb3cb3a78381 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 18:51:52 +0200 Subject: [PATCH 0302/1118] Bump python-open-router to 0.3.0 (#148900) --- homeassistant/components/open_router/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 64b7319a902..fab62e7971c 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.93.3", "python-open-router==0.2.0"] + "requirements": ["openai==1.93.3", "python-open-router==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 09800ef9e94..887e82a6c76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2478,7 +2478,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.2.0 +python-open-router==0.3.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8dfe7a8edac..b19e7dcbdd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2051,7 +2051,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.2.0 +python-open-router==0.3.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 From e8fca193355e82d77dc7c82316577759efb027b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 16 Jul 2025 21:40:44 +0200 Subject: [PATCH 0303/1118] Fix flaky husqvarna_automower test with comprehensive race condition fix (#148911) Co-authored-by: Claude --- .../husqvarna_automower/calendar.py | 4 ++++ .../components/husqvarna_automower/entity.py | 5 ++++ .../husqvarna_automower/test_init.py | 23 ++++++++++--------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index b4d3d2176af..ac7447bc3c0 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -70,6 +70,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): @property def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" + if not self.available: + return None schedule = self.mower_attributes.calendar cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) @@ -94,6 +96,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): This is only called when opening the calendar in the UI. """ + if not self.available: + return [] schedule = self.mower_attributes.calendar cursor = schedule.timeline.overlapping( start_date, diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 150a3d18d87..3ccb098262f 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -114,6 +114,11 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): """Get the mower attributes of the current mower.""" return self.coordinator.data[self.mower_id] + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_id in self.coordinator.data + class AutomowerAvailableEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index f54250a3336..d4921bf661d 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -312,8 +312,9 @@ async def test_coordinator_automatic_registry_cleanup( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) # Remove mower 2 and check if it worked - mower2 = values.pop("1234") - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower2 = values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -327,8 +328,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 2 and check if it worked - values["1234"] = mower2 - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + values_copy["1234"] = mower2 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -342,8 +344,9 @@ async def test_coordinator_automatic_registry_cleanup( ) # Remove mower 1 and check if it worked - mower1 = values.pop(TEST_MOWER_ID) - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower1 = values_copy.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -357,11 +360,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 1 and check if it worked - values[TEST_MOWER_ID] = mower1 - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + values_copy = deepcopy(values) + values_copy[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From 9d178ad5f1f94be776a3d78914e8b2f8e75cc1b0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:45:22 +0200 Subject: [PATCH 0304/1118] Deprecate the usage of ContextVar for config_entry in coordinator (#138161) --- homeassistant/helpers/update_coordinator.py | 14 ++- tests/helpers/test_update_coordinator.py | 113 ++++++++++++++++++-- tests/test_config_entries.py | 4 + 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index bd85391f98f..6b566797017 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,9 +84,19 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.update_interval = update_interval self._shutdown_requested = False if config_entry is UNDEFINED: + # late import to avoid circular imports + from . import frame # noqa: PLC0415 + + # It is not planned to enforce this for custom integrations. + # see https://github.com/home-assistant/core/pull/138161#discussion_r1958184241 + frame.report_usage( + "relies on ContextVar, but should pass the config entry explicitly.", + core_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + ) + self.config_entry = config_entries.current_entry.get() - # This should be deprecated once all core integrations are updated - # to pass in the config entry explicitly. else: self.config_entry = config_entry self.always_update = always_update diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 5fd9f9e39fd..b4216a3fc6d 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import update_coordinator +from homeassistant.helpers import frame, update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -165,8 +165,6 @@ async def test_shutdown_on_entry_unload( ) -> None: """Test shutdown is requested on entry unload.""" entry = MockConfigEntry() - config_entries.current_entry.set(entry) - calls = 0 async def _refresh() -> int: @@ -177,6 +175,7 @@ async def test_shutdown_on_entry_unload( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=entry, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -206,6 +205,7 @@ async def test_shutdown_on_hass_stop( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -843,6 +843,7 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: crd = update_coordinator.TimestampDataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=refresh, update_interval=timedelta(seconds=10), @@ -865,39 +866,133 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: assert len(last_update_success_times) == 1 -async def test_config_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "integration_frame_path", ["homeassistant/components/my_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_config_entry( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test behavior of coordinator.entry.""" entry = MockConfigEntry() - # Default without context should be None - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - # Explicit None is OK crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry is OK + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry different from ContextVar not recommended, but should work + another_entry = MockConfigEntry() + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=another_entry + ) + assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Default without context should log a warning + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + + # Default with context should log a warning + caplog.clear() + frame._REPORTED_INTEGRATIONS.clear() + config_entries.current_entry.set(entry) + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + assert crd.config_entry is entry + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("hass", "mock_integration_frame") +async def test_config_entry_custom_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test behavior of coordinator.entry for custom integrations.""" + entry = MockConfigEntry(domain="custom_integration") + + # Default without context should be None + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit None is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=None + ) + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=entry + ) + assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: @@ -920,7 +1015,7 @@ async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> self._unsub = None coordinator = update_coordinator.DataUpdateCoordinator[int]( - hass, _LOGGER, name="test" + hass, _LOGGER, config_entry=None, name="test" ) subscriber = Subscriber() subscriber.start_listen(coordinator) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index dc893e4c5fd..7fb632e18b5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4901,6 +4901,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -4941,6 +4942,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5020,6 +5022,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5072,6 +5075,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) From 5b41d5a7952ecd608820e753d0a6261a22041733 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 16 Jul 2025 21:50:29 +0200 Subject: [PATCH 0305/1118] Fix typo "barametric" in `rainmachine` (#148917) --- homeassistant/components/rainmachine/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 49731df5b6f..e8c54c94f84 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -240,8 +240,8 @@ "description": "Current weather condition code (WNUM)." }, "pressure": { - "name": "Barametric pressure", - "description": "Current barametric pressure (kPa)." + "name": "Barometric pressure", + "description": "Current barometric pressure (kPa)." }, "dewpoint": { "name": "Dew point", From a5c301db1be6b8f69da1d54619eb227f9a44660b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 16 Jul 2025 21:55:37 +0200 Subject: [PATCH 0306/1118] Add code review guidelines to exclude imports and formatting feedback (#148912) --- .github/copilot-instructions.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 603cf407081..7eba0203f7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,6 +45,12 @@ rules: **When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. +## Code Review Guidelines + +**When reviewing code, do NOT comment on:** +- **Missing imports** - We use static analysis tooling to catch that +- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) + ## Python Requirements - **Compatibility**: Python 3.13+ From 83cd2dfef3765660a46859a1faf57ddbecbd9e96 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 16 Jul 2025 22:12:35 +0200 Subject: [PATCH 0307/1118] Bump aioautomower to 2.0.0 (#148846) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index fb717a5615f..d747bc00094 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==1.2.2"] + "requirements": ["aioautomower==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 887e82a6c76..9aac7e73049 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b19e7dcbdd3..3339762dd58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index c58a12ad007..170fbe7ad82 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,8 +63,6 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'messages': list([ - ]), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', From 6dc2340c5ab5cb4ec51d22f99659baa88ce7e96f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 17 Jul 2025 00:15:45 +0200 Subject: [PATCH 0308/1118] Fix docstring for WaitIntegrationOnboardingView (#148904) --- homeassistant/components/onboarding/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a897d04562f..a89a98a7fcf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -317,7 +317,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): - """Get backup info view.""" + """View to wait for an integration.""" url = "/api/onboarding/integration/wait" name = "api:onboarding:integration:wait" From e32e06d7a0adf68cf84717006e3cce65ae5f13aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 17 Jul 2025 07:52:59 +0100 Subject: [PATCH 0309/1118] Fix Husqvarna Automower coordinator listener list mutating (#148926) --- .../components/husqvarna_automower/coordinator.py | 8 +++++++- tests/components/husqvarna_automower/test_init.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 342f6892b2e..7fc1e628e27 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta import logging +from typing import override from aioautomower.exceptions import ( ApiError, @@ -60,7 +61,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - self.async_add_listener(self._on_data_update) + + @override + @callback + def async_update_listeners(self) -> None: + self._on_data_update() + super().async_update_listeners() async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index d4921bf661d..81874cea8a7 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -462,7 +462,13 @@ async def test_add_and_remove_work_area( poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") del poll_values[TEST_MOWER_ID].work_area_dict[123456] del poll_values[TEST_MOWER_ID].work_areas[123456] - del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + + poll_values[TEST_MOWER_ID].calendar.tasks = [ + task + for task in poll_values[TEST_MOWER_ID].calendar.tasks + if task.work_area_id not in [1, 123456] + ] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) From ae03fc22955b209350b0dfd28cbf5ce2bdbcbd1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 17 Jul 2025 08:55:47 +0200 Subject: [PATCH 0310/1118] Fix missing unit of measurement in tuya numbers (#148924) --- homeassistant/components/tuya/number.py | 2 ++ tests/components/tuya/snapshots/test_number.ambr | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 68777d75a90..5aee426da8c 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -381,6 +381,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_native_max_value = self._number.max_scaled self._attr_native_min_value = self._number.min_scaled self._attr_native_step = self._number.step_scaled + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = int_type.unit # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 125a0680de9..1b19d5827ab 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -95,7 +95,7 @@ 'supported_features': 0, 'translation_key': 'feed', 'unique_id': 'tuya.bfd0273e59494eb34esvrxmanual_feed', - 'unit_of_measurement': None, + 'unit_of_measurement': '', }) # --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-state] @@ -106,6 +106,7 @@ 'min': 1.0, 'mode': , 'step': 1.0, + 'unit_of_measurement': '', }), 'context': , 'entity_id': 'number.cleverio_pf100_feed', @@ -152,7 +153,7 @@ 'supported_features': 0, 'translation_key': 'temp_correction', 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', - 'unit_of_measurement': None, + 'unit_of_measurement': '℃', }) # --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] @@ -163,6 +164,7 @@ 'min': -9.9, 'mode': , 'step': 0.1, + 'unit_of_measurement': '℃', }), 'context': , 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', From 656822b39ceeb623dbbb40a251ecd57258c2ba30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 17 Jul 2025 08:57:11 +0200 Subject: [PATCH 0311/1118] Bump letpot to 0.5.0 (#148922) --- homeassistant/components/letpot/__init__.py | 9 +++- .../components/letpot/binary_sensor.py | 4 +- .../components/letpot/coordinator.py | 13 ++--- homeassistant/components/letpot/entity.py | 5 +- homeassistant/components/letpot/manifest.json | 3 +- homeassistant/components/letpot/sensor.py | 8 ++- homeassistant/components/letpot/switch.py | 30 ++++++++--- homeassistant/components/letpot/time.py | 16 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/conftest.py | 52 ++++++++++++------- tests/components/letpot/test_init.py | 2 +- tests/components/letpot/test_switch.py | 5 +- tests/components/letpot/test_time.py | 5 +- 14 files changed, 105 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 50c73f949a3..4b84a023675 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -6,6 +6,7 @@ import asyncio from letpot.client import LetPotClient from letpot.converters import CONVERTERS +from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo @@ -68,8 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo except LetPotException as exc: raise ConfigEntryNotReady from exc + device_client = LetPotDeviceClient(auth) + coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, entry, auth, device) + LetPotDeviceCoordinator(hass, entry, device, device_client) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] @@ -92,5 +95,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> b """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for coordinator in entry.runtime_data: - coordinator.device_client.disconnect() + await coordinator.device_client.unsubscribe( + coordinator.device.serial_number + ) return unload_ok diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py index bfc7a5ab4a7..e5939abc24d 100644 --- a/homeassistant/components/letpot/binary_sensor.py +++ b/homeassistant/components/letpot/binary_sensor.py @@ -58,7 +58,9 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, supported_fn=( lambda coordinator: DeviceFeature.PUMP_STATUS - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotBinarySensorEntityDescription( diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 39e49348663..0ef2c563f38 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -8,7 +8,7 @@ import logging from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from letpot.models import LetPotDevice, LetPotDeviceStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,8 +34,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): self, hass: HomeAssistant, config_entry: LetPotConfigEntry, - info: AuthenticationInfo, device: LetPotDevice, + device_client: LetPotDeviceClient, ) -> None: """Initialize coordinator.""" super().__init__( @@ -45,9 +45,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): name=f"LetPot {device.serial_number}", update_interval=timedelta(minutes=10), ) - self._info = info self.device = device - self.device_client = LetPotDeviceClient(info, device.serial_number) + self.device_client = device_client def _handle_status_update(self, status: LetPotDeviceStatus) -> None: """Distribute status update to entities.""" @@ -56,7 +55,9 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): async def _async_setup(self) -> None: """Set up subscription for coordinator.""" try: - await self.device_client.subscribe(self._handle_status_update) + await self.device_client.subscribe( + self.device.serial_number, self._handle_status_update + ) except LetPotAuthenticationException as exc: raise ConfigEntryAuthFailed from exc @@ -64,7 +65,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Request an update from the device and wait for a status update or timeout.""" try: async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): - await self.device_client.get_current_status() + await self.device_client.get_current_status(self.device.serial_number) except LetPotException as exc: raise UpdateFailed(exc) from exc diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index 5e2c46fee84..11d6a132a18 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -30,12 +30,13 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: """Initialize a LetPot entity.""" super().__init__(coordinator) + info = coordinator.device_client.device_info(coordinator.device.serial_number) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.device.serial_number)}, name=coordinator.device.name, manufacturer="LetPot", - model=coordinator.device_client.device_model_name, - model_id=coordinator.device_client.device_model_code, + model=info.model_name, + model_id=info.model_code, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index d08b5f70a51..6ee6a309cac 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/letpot", "integration_type": "hub", "iot_class": "cloud_push", + "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.4.0"] + "requirements": ["letpot==0.5.0"] } diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py index b0b113eb063..841b8720616 100644 --- a/homeassistant/components/letpot/sensor.py +++ b/homeassistant/components/letpot/sensor.py @@ -50,7 +50,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.TEMPERATURE - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSensorEntityDescription( @@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.WATER_LEVEL - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), ) diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 0b00318c53b..d22bc85f116 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -25,7 +25,7 @@ class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescrip """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] - set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]] SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( @@ -33,7 +33,9 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="alarm_sound", translation_key="alarm_sound", value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_sound(serial, value) + ), entity_category=EntityCategory.CONFIG, supported_fn=lambda coordinator: coordinator.data.system_sound is not None, ), @@ -41,25 +43,35 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="auto_mode", translation_key="auto_mode", value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_water_mode( + serial, value + ) + ), entity_category=EntityCategory.CONFIG, supported_fn=( lambda coordinator: DeviceFeature.PUMP_AUTO - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSwitchEntityDescription( key="power", translation_key="power", value_fn=lambda status: status.system_on, - set_value_fn=lambda device_client, value: device_client.set_power(value), + set_value_fn=lambda device_client, serial, value: device_client.set_power( + serial, value + ), entity_category=EntityCategory.CONFIG, ), LetPotSwitchEntityDescription( key="pump_cycling", translation_key="pump_cycling", value_fn=lambda status: status.pump_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_pump_mode(value), + set_value_fn=lambda device_client, serial, value: device_client.set_pump_mode( + serial, value + ), entity_category=EntityCategory.CONFIG, ), ) @@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity): @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_value_fn(self.coordinator.device_client, True) + await self.entity_description.set_value_fn( + self.coordinator.device_client, self.coordinator.device.serial_number, True + ) @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, False + self.coordinator.device_client, self.coordinator.device.serial_number, False ) diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index bae61df6a28..87ce35f828d 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -26,7 +26,7 @@ class LetPotTimeEntityDescription(TimeEntityDescription): """Describes a LetPot time entity.""" value_fn: Callable[[LetPotDeviceStatus], time | None] - set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]] TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( @@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_end", translation_key="light_schedule_end", value_fn=lambda status: None if status is None else status.light_schedule_end, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=None, end=value + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=None, end=value + ) ), entity_category=EntityCategory.CONFIG, ), @@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_start", translation_key="light_schedule_start", value_fn=lambda status: None if status is None else status.light_schedule_start, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=value, end=None + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=value, end=None + ) ), entity_category=EntityCategory.CONFIG, ), @@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, value + self.coordinator.device_client, self.coordinator.device.serial_number, value ) diff --git a/requirements_all.txt b/requirements_all.txt index 9aac7e73049..9267aa3f2bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1334,7 +1334,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3339762dd58..0b41f72e888 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1153,7 +1153,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 25974b2d78a..6d59f8bd2ef 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,12 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus +from letpot.models import ( + DeviceFeature, + LetPotDevice, + LetPotDeviceInfo, + LetPotDeviceStatus, +) import pytest from homeassistant.components.letpot.const import ( @@ -26,6 +31,16 @@ def device_type() -> str: return "LPH63" +def _mock_device_info(device_type: str) -> LetPotDeviceInfo: + """Return mock device info for the given type.""" + return LetPotDeviceInfo( + model=device_type, + model_name=f"LetPot {device_type}", + model_code=device_type, + features=_mock_device_features(device_type), + ) + + def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": @@ -89,32 +104,33 @@ def mock_client(device_type: str) -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client(device_type: str) -> Generator[AsyncMock]: +def mock_device_client() -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( - "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + "homeassistant.components.letpot.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = _mock_device_features(device_type) - device_client.device_model_code = device_type - device_client.device_model_name = f"LetPot {device_type}" - device_status = _mock_device_status(device_type) - subscribe_callbacks: list[Callable] = [] + subscribe_callbacks: dict[str, Callable] = {} - def subscribe_side_effect(callback: Callable) -> None: - subscribe_callbacks.append(callback) + def subscribe_side_effect(serial: str, callback: Callable) -> None: + subscribe_callbacks[serial] = callback - def status_side_effect() -> None: - # Deliver a status update to any subscribers, like the real client - for callback in subscribe_callbacks: - callback(device_status) + def request_status_side_effect(serial: str) -> None: + # Deliver a status update to the subscriber, like the real client + if (callback := subscribe_callbacks.get(serial)) is not None: + callback(_mock_device_status(serial[:5])) - device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = device_status - device_client.last_status.return_value = device_status - device_client.request_status_update.side_effect = status_side_effect + def get_current_status_side_effect(serial: str) -> LetPotDeviceStatus: + request_status_side_effect(serial) + return _mock_device_status(serial[:5]) + + device_client.device_info.side_effect = lambda serial: _mock_device_info( + serial[:5] + ) + device_client.get_current_status.side_effect = get_current_status_side_effect + device_client.request_status_update.side_effect = request_status_side_effect device_client.subscribe.side_effect = subscribe_side_effect yield device_client diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index e3f78d87dc1..8357b4da67e 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -37,7 +37,7 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - mock_device_client.disconnect.assert_called_once() + mock_device_client.unsubscribe.assert_called_once() @pytest.mark.freeze_time("2025-02-15 00:00:00") diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 7eeafd78291..b1b4b48b7bb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -58,6 +58,7 @@ async def test_set_switch( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, service: str, parameter_value: bool, ) -> None: @@ -71,7 +72,9 @@ async def test_set_switch( target={"entity_id": "switch.garden_power"}, ) - mock_device_client.set_power.assert_awaited_once_with(parameter_value) + mock_device_client.set_power.assert_awaited_once_with( + f"{device_type}ABCD", parameter_value + ) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index dba51ce8497..5c84b6a0159 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -38,6 +38,7 @@ async def test_set_time( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, ) -> None: """Test setting the time entity.""" await setup_integration(hass, mock_config_entry) @@ -50,7 +51,9 @@ async def test_set_time( target={"entity_id": "time.garden_light_on"}, ) - mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + mock_device_client.set_light_schedule.assert_awaited_once_with( + f"{device_type}ABCD", time(7, 0), None + ) @pytest.mark.parametrize( From 9def44dca472a9e36064e193c357dddf5d373e00 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 17 Jul 2025 08:58:53 +0200 Subject: [PATCH 0312/1118] Bump inexogy quality scale to platinum (#148908) --- .../components/discovergy/manifest.json | 2 +- .../components/discovergy/quality_scale.yaml | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 2f74928c19e..d3443eaefdf 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index a8f140f258c..db49639b937 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -57,13 +57,16 @@ rules: status: exempt comment: | This integration cannot be discovered, it is a connecting to a cloud service. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: | + The integration does not have any known limitations. + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | From a0991134c46171765e336baced4980a7ab5c09f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 17 Jul 2025 08:59:34 +0200 Subject: [PATCH 0313/1118] Rename tuya fixture file to match category (#148892) --- tests/components/tuya/__init__.py | 2 +- ...gbee_cover.json => cl_am43_corded_motor_zigbee_cover.json} | 0 tests/components/tuya/snapshots/test_cover.ambr | 4 ++-- tests/components/tuya/snapshots/test_select.ambr | 4 ++-- tests/components/tuya/test_cover.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename tests/components/tuya/fixtures/{am43_corded_motor_zigbee_cover.json => cl_am43_corded_motor_zigbee_cover.json} (100%) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c3d6c31924e..5134410a293 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { - "am43_corded_motor_zigbee_cover": [ + "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 Platform.SELECT, Platform.COVER, diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json similarity index 100% rename from tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json rename to tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 1ab635919ca..6ae4781c7c1 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 48, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index a2d52a893c9..0f530184122 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kitchen Blinds Motor mode', diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 4550ed9d6f4..3b190e46827 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -59,7 +59,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["am43_corded_motor_zigbee_cover"], + ["cl_am43_corded_motor_zigbee_cover"], ) @pytest.mark.parametrize( ("percent_control", "percent_state"), From 5383ff96ef74bc95b17eb3d746504aaa55a720e3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 17 Jul 2025 09:00:44 +0200 Subject: [PATCH 0314/1118] Make sure gardena bluetooth mock unload if it mocks load (#148920) --- tests/components/gardena_bluetooth/conftest.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index d363e0e69f3..0f877fce7db 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -29,8 +29,18 @@ def mock_entry(): ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.gardena_bluetooth.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gardena_bluetooth.async_setup_entry", From 3d278b626afa7c3414a24a24cb34780dd2ac1bd0 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 09:19:44 +0200 Subject: [PATCH 0315/1118] Z-Wave JS: Add statistics sensors for channel 3 background RSSI (#148899) --- homeassistant/components/zwave_js/sensor.py | 19 +++++++++++++++++++ tests/components/zwave_js/test_sensor.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index ac65b9e2749..f62e6e1a9f2 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -470,6 +470,23 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ state_class=SensorStateClass.MEASUREMENT, convert=convert_nested_attr, ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.average", + translation_key="average_background_rssi", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_nested_attr, + ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.current", + translation_key="current_background_rssi", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + convert=convert_nested_attr, + ), ] CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { @@ -488,6 +505,8 @@ CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { "background_rssi.channel_1.current": "backgroundRSSI.channel1.current", "background_rssi.channel_2.average": "backgroundRSSI.channel2.average", "background_rssi.channel_2.current": "backgroundRSSI.channel2.current", + "background_rssi.channel_3.average": "backgroundRSSI.channel3.average", + "background_rssi.channel_3.current": "backgroundRSSI.channel3.current", } # Node statistics descriptions diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index ef77e22bbec..a005d374b31 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -800,8 +800,10 @@ CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { "average_background_rssi_channel_0": -2, "current_background_rssi_channel_1": -3, "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": STATE_UNKNOWN, - "average_background_rssi_channel_2": STATE_UNKNOWN, + "current_background_rssi_channel_2": -5, + "average_background_rssi_channel_2": -6, + "current_background_rssi_channel_3": STATE_UNKNOWN, + "average_background_rssi_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -944,6 +946,10 @@ async def test_statistics_sensors_no_last_seen( "current": -3, "average": -4, }, + "channel2": { + "current": -5, + "average": -6, + }, "timestamp": 1681967176510, }, }, From 72d1c3cfc8dceb9cae3a250c1003e5bf9b4e5a7d Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 17 Jul 2025 08:47:54 +0100 Subject: [PATCH 0316/1118] Fix Tuya support for climate fan modes which use "windspeed" function (#148646) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/tuya/climate.py | 16 +++- tests/components/tuya/__init__.py | 4 + ...erenelife_slpac905wuk_air_conditioner.json | 80 +++++++++++++++++++ .../tuya/snapshots/test_climate.ambr | 75 +++++++++++++++++ tests/components/tuya/test_climate.py | 64 +++++++++++++++ 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 734f6ba7f7a..d8907b0db9d 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -250,6 +250,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ) # Determine fan modes + self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), dptype=DPType.ENUM, @@ -257,6 +258,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ): self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = enum_type.range + self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes if self.find_dpcode( @@ -304,7 +306,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) + if TYPE_CHECKING: + # We can rely on supported_features from __init__ + assert self._fan_mode_dp_code is not None + + self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -460,7 +466,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return fan mode.""" - return self.device.status.get(DPCode.FAN_SPEED_ENUM) + return ( + self.device.status.get(self._fan_mode_dp_code) + if self._fan_mode_dp_code + else None + ) @property def swing_mode(self) -> str: diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5134410a293..2286cf016c3 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -107,6 +107,10 @@ DEVICE_MOCKS = { Platform.LIGHT, Platform.SWITCH, ], + "kt_serenelife_slpac905wuk_air_conditioner": [ + # https://github.com/home-assistant/core/pull/148646 + Platform.CLIMATE, + ], "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, diff --git a/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json new file mode 100644 index 00000000000..8fa2d7b0512 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json @@ -0,0 +1,80 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Air Conditioner", + "category": "kt", + "product_id": "5wnlzekkstwcdsvm", + "product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-06T10:10:44+00:00", + "create_time": "2025-07-06T10:10:44+00:00", + "update_time": "2025-07-06T10:10:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": -7, + "max": 98, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 23, + "temp_current": 22, + "windspeed": 1 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 4360ef7f436..42fc10fef54 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -1,4 +1,79 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'fan_mode': 1, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Air Conditioner', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index a5117983000..d564c027cd1 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -11,6 +11,7 @@ from tuya_sharing import CustomerDevice from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -55,3 +56,66 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_windspeed( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get("climate.air_conditioner") + assert state is not None, "climate.air_conditioner does not exist" + assert state.attributes["fan_mode"] == 1 + await hass.services.async_call( + Platform.CLIMATE, + "set_fan_mode", + { + "entity_id": "climate.air_conditioner", + "fan_mode": 2, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "windspeed", "value": "2"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_no_valid_code( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with no valid code.""" + # Remove windspeed DPCode to simulate a device with no valid fan mode + mock_device.function.pop("windspeed", None) + mock_device.status_range.pop("windspeed", None) + mock_device.status.pop("windspeed", None) + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get("climate.air_conditioner") + assert state is not None, "climate.air_conditioner does not exist" + assert state.attributes.get("fan_mode") is None + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + Platform.CLIMATE, + "set_fan_mode", + { + "entity_id": "climate.air_conditioner", + "fan_mode": 2, + }, + blocking=True, + ) From 79b8e74d8735afd8eef7ffda8222ce046836ee27 Mon Sep 17 00:00:00 2001 From: asafhas <121308170+asafhas@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:26:33 +0300 Subject: [PATCH 0317/1118] Add numbers configuration to Tuya alarm (#148907) --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/number.py | 24 +++ homeassistant/components/tuya/strings.json | 9 + tests/components/tuya/__init__.py | 1 + .../tuya/snapshots/test_number.ambr | 177 ++++++++++++++++++ 5 files changed, 213 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index b8bb5ea483f..87f80755e8b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -98,6 +98,7 @@ class DPCode(StrEnum): AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" + ALARM_DELAY_TIME = "alarm_delay_time" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume @@ -176,6 +177,7 @@ class DPCode(StrEnum): DECIBEL_SWITCH = "decibel_switch" DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAY_SET = "delay_set" DISINFECTION = "disinfection" DO_NOT_DISTURB = "do_not_disturb" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 5aee426da8c..415299307e3 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -170,6 +170,30 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + NumberEntityDescription( + key=DPCode.DELAY_SET, + # This setting is called "Arm Delay" in the official Tuya app + translation_key="arm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_DELAY_TIME, + translation_key="alarm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_TIME, + # This setting is called "Siren Duration" in the official Tuya app + translation_key="siren_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ee1df183f36..799d57547b2 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -222,6 +222,15 @@ }, "temp_correction": { "name": "Temperature correction" + }, + "arm_delay": { + "name": "Arm delay" + }, + "alarm_delay": { + "name": "Alarm delay" + }, + "siren_duration": { + "name": "Siren duration" } }, "select": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 2286cf016c3..1ce7e6c47dd 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -114,6 +114,7 @@ DEVICE_MOCKS = { "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, + Platform.NUMBER, Platform.SWITCH, ], "mcs_door_sensor": [ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 1b19d5827ab..1c8af00baff 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -116,6 +116,183 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + '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 delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'tuya.123123aba12312312dazubalarm_delay_time', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Alarm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_delay', + 'unique_id': 'tuya.123123aba12312312dazubdelay_set', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Arm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Siren duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_duration', + 'unique_id': 'tuya.123123aba12312312dazubalarm_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Siren duration', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0e6a1e324279ccc406176422333106ef2debe95b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:41:39 +0200 Subject: [PATCH 0318/1118] Improve integration sensor tests (#148938) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: G Johansson --- tests/components/integration/test_sensor.py | 101 +++++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index ba4a6bdf198..3d5549d88bf 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -294,23 +294,35 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ + # time, value, attributes, expected ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 8.75), - (60, 0, 9.17), + (0, 0, {}, 0), + (20, 10, {}, 1.67), + (30, 30, {}, 5.0), + (40, 5, {}, 7.92), + (50, 5, {}, 8.75), # This fires a state report + (60, 0, {}, 9.17), + ), + ( + (0, 0, {}, 0), + (20, 10, {}, 1.67), + (30, 30, {}, 5.0), + (40, 5, {}, 7.92), + (50, 5, {"foo": "bar"}, 8.75), # This fires a state change + (60, 0, {}, 9.17), ), ], ) async def test_trapezoidal( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: """Test integration sensor state.""" config = { @@ -320,23 +332,29 @@ async def test_trapezoidal( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -346,25 +364,35 @@ async def test_trapezoidal( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ + # time, value, attributes, expected ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 7.5), - (60, 0, 8.33), + (20, 10, {}, 0.0), + (30, 30, {}, 1.67), + (40, 5, {}, 6.67), + (50, 5, {}, 7.5), # This fires a state report + (60, 0, {}, 8.33), + ), + ( + (20, 10, {}, 0.0), + (30, 30, {}, 1.67), + (40, 5, {}, 6.67), + (50, 5, {"foo": "bar"}, 7.5), # This fires a state change + (60, 0, {}, 8.33), ), ], ) async def test_left( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with left Riemann method.""" config = { "sensor": { "platform": "integration", @@ -373,25 +401,31 @@ async def test_left( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -401,25 +435,34 @@ async def test_left( assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 10.0), - (60, 0, 10.0), + (20, 10, {}, 3.33), + (30, 30, {}, 8.33), + (40, 5, {}, 9.17), + (50, 5, {}, 10.0), # This fires a state report + (60, 0, {}, 10.0), + ), + ( + (20, 10, {}, 3.33), + (30, 30, {}, 8.33), + (40, 5, {}, 9.17), + (50, 5, {"foo": "bar"}, 10.0), # This fires a state change + (60, 0, {}, 10.0), ), ], ) async def test_right( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with right Riemann method.""" config = { "sensor": { "platform": "integration", @@ -428,25 +471,31 @@ async def test_right( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() From d72fb021c1bfa00b910c7c0c670c28ad4da22a49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:42:25 +0200 Subject: [PATCH 0319/1118] Improve statistics tests (#148937) --- tests/components/statistics/test_sensor.py | 99 ++++++++++++++-------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 21df0146ef5..1db4acf3ef8 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -54,6 +54,9 @@ VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] VALUES_NUMERIC_LINEAR = [1, 2, 3, 4, 5, 6, 7, 8, 9] +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} + async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -249,7 +252,22 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert "age_coverage_ratio" not in state.attributes -async def test_sensor_state_reported(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values", "attributes"), + [ + # Fires last reported events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A1, A1, A1, A1, A1, A1, A1, A1]), + # Fires state change events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A2, A1, A2, A1, A2, A1, A2, A1]), + ], +) +async def test_sensor_state_updated_reported( + hass: HomeAssistant, + values: list[float], + attributes: list[dict[str, Any]], + force_update: bool, +) -> None: """Test the behavior of the sensor with a sequence of identical values. Forced updates no longer make a difference, since the statistics are now reacting not @@ -258,7 +276,6 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: This fixes problems with time based averages and some other functions that behave differently when repeating values are reported. """ - repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] assert await async_setup_component( hass, "sensor", @@ -267,14 +284,7 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: { "platform": "statistics", "name": "test_normal", - "entity_id": "sensor.test_monitored_normal", - "state_characteristic": "mean", - "sampling_size": 20, - }, - { - "platform": "statistics", - "name": "test_force", - "entity_id": "sensor.test_monitored_force", + "entity_id": "sensor.test_monitored", "state_characteristic": "mean", "sampling_size": 20, }, @@ -283,27 +293,19 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value in repeating_values: + for value, attribute in zip(values, attributes, strict=True): hass.states.async_set( - "sensor.test_monitored_normal", + "sensor.test_monitored", str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - ) - hass.states.async_set( - "sensor.test_monitored_force", - str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - force_update=True, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} | attribute, + force_update=force_update, ) await hass.async_block_till_done() - state_normal = hass.states.get("sensor.test_normal") - state_force = hass.states.get("sensor.test_force") - assert state_normal and state_force - assert state_normal.state == str(round(sum(repeating_values) / 9, 2)) - assert state_force.state == str(round(sum(repeating_values) / 9, 2)) - assert state_normal.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + state = hass.states.get("sensor.test_normal") + assert state + assert state.state == str(round(sum(values) / 9, 2)) + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) async def test_sampling_boundaries_given(hass: HomeAssistant) -> None: @@ -1785,12 +1787,40 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) assert float(hass.states.get("sensor.test").state) == pytest.approx(4.5) -async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values_attributes_and_times", "expected_state"), + [ + ( + # Fires last reported events + [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], + "8.33", + ), + ( # Fires state change events + [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], + "8.33", + ), + ( + # Fires last reported events + [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], + "10.0", + ), + ( # Fires state change events + [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], + "10.0", + ), + ], +) +async def test_average_linear_unevenly_timed( + hass: HomeAssistant, + force_update: bool, + values_attributes_and_times: list[tuple[float, dict[str, Any], float]], + expected_state: str, +) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ - values_and_times = [[5.0, 2], [10.0, 1], [10.0, 1], [10.0, 2], [5.0, 1]] current_time = dt_util.utcnow() @@ -1814,22 +1844,23 @@ async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value_and_time in values_and_times: + for value, extra_attributes, time in values_attributes_and_times: hass.states.async_set( "sensor.test_monitored", - str(value_and_time[0]), - {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + str(value), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE} | extra_attributes, + force_update=force_update, ) - current_time += timedelta(seconds=value_and_time[1]) + current_time += timedelta(seconds=time) freezer.move_to(current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test_sensor_average_linear") assert state is not None - assert state.state == "8.33", ( + assert state.state == expected_state, ( "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == 8.33" + f"assert {state.state} == {expected_state}" ) From 9373bb287c620ef1c1033ef27d0b2a14dcf6da03 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Thu, 17 Jul 2025 11:43:26 +0200 Subject: [PATCH 0320/1118] Huum - Introduce coordinator to support multiple platforms (#148889) Co-authored-by: Josef Zweck --- CODEOWNERS | 4 +- homeassistant/components/huum/__init__.py | 46 +++---- homeassistant/components/huum/climate.py | 53 ++++----- homeassistant/components/huum/config_flow.py | 4 +- homeassistant/components/huum/coordinator.py | 60 ++++++++++ homeassistant/components/huum/manifest.json | 2 +- tests/components/huum/__init__.py | 17 +++ tests/components/huum/conftest.py | 72 +++++++++++ .../huum/snapshots/test_climate.ambr | 68 +++++++++++ tests/components/huum/test_climate.py | 78 ++++++++++++ tests/components/huum/test_config_flow.py | 112 +++++++----------- tests/components/huum/test_init.py | 27 +++++ 12 files changed, 403 insertions(+), 140 deletions(-) create mode 100644 homeassistant/components/huum/coordinator.py create mode 100644 tests/components/huum/conftest.py create mode 100644 tests/components/huum/snapshots/test_climate.ambr create mode 100644 tests/components/huum/test_climate.py create mode 100644 tests/components/huum/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 05c17b5498d..f4f1d3b7a92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -684,8 +684,8 @@ build.json @home-assistant/supervisor /tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/husqvarna_automower_ble/ @alistair23 /tests/components/husqvarna_automower_ble/ @alistair23 -/homeassistant/components/huum/ @frwickst -/tests/components/huum/ @frwickst +/homeassistant/components/huum/ @frwickst @vincentwolsink +/tests/components/huum/ @frwickst @vincentwolsink /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df..d2dd7ff4fa3 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -2,46 +2,28 @@ from __future__ import annotations -import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) +from .const import PLATFORMS +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HuumConfigEntry) -> bool: """Set up Huum from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] + coordinator = HuumDataUpdateCoordinator( + hass=hass, + config_entry=config_entry, + ) - huum = Huum(username, password, session=async_get_clientsession(hass)) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator - try: - await huum.status() - except (Forbidden, NotAuthenticated) as err: - _LOGGER.error("Could not log in to Huum with given credentials") - raise ConfigEntryNotReady( - "Could not log in to Huum with given credentials" - ) from err - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HuumConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index bbeb50a2b72..b0d36a56a46 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -7,38 +7,35 @@ from typing import Any from huum.const import SaunaStatus from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HuumConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Huum sauna with config flow.""" - huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] - - async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(ClimateEntity): +class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -54,24 +51,22 @@ class HuumDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None - _target_temperature: int | None = None - _status: HuumStatusResponse | None = None - - def __init__(self, huum_handler: Huum, unique_id: str) -> None: + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: """Initialize the heater.""" - self._attr_unique_id = unique_id + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Huum sauna", manufacturer="Huum", + model="UKU WiFi", ) - self._huum_handler = huum_handler - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + if self.coordinator.data.status == SaunaStatus.ONLINE_HEATING: return HVACMode.HEAT return HVACMode.OFF @@ -85,41 +80,33 @@ class HuumDevice(ClimateEntity): @property def current_temperature(self) -> int | None: """Return the current temperature.""" - if (status := self._status) is not None: - return status.temperature - return None + return self.coordinator.data.temperature @property def target_temperature(self) -> int: """Return the temperature we try to reach.""" - return self._target_temperature or int(self.min_temp) + return self.coordinator.data.target_temperature or int(self.min_temp) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: await self._turn_on(self.target_temperature) elif hvac_mode == HVACMode.OFF: - await self._huum_handler.turn_off() + await self.coordinator.huum.turn_off() + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if temperature is None or self.hvac_mode != HVACMode.HEAT: return - self._target_temperature = temperature - if self.hvac_mode == HVACMode.HEAT: - await self._turn_on(temperature) - - async def async_update(self) -> None: - """Get the latest status data.""" - self._status = await self._huum_handler.status() - if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: - self._target_temperature = self._status.target_temperature + await self._turn_on(temperature) + await self.coordinator.async_refresh() async def _turn_on(self, temperature: int) -> None: try: - await self._huum_handler.turn_on(temperature) + await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: _LOGGER.error(str(err)) raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d..b6f7f883120 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -37,12 +37,12 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - huum_handler = Huum( + huum = Huum( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=async_get_clientsession(self.hass), ) - await huum_handler.status() + await huum.status() except (Forbidden, NotAuthenticated): # Most likely Forbidden as that is what is returned from `.status()` with bad creds _LOGGER.error("Could not log in to Huum with given credentials") diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py new file mode 100644 index 00000000000..6580ca99da7 --- /dev/null +++ b/homeassistant/components/huum/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for Huum.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type HuumConfigEntry = ConfigEntry[HuumDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=30) + + +class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]): + """Class to manage fetching data from the API.""" + + config_entry: HuumConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HuumConfigEntry, + ) -> None: + """Initialize.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + self.huum = Huum( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> HuumStatusResponse: + """Get the latest status data.""" + + try: + return await self.huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise UpdateFailed( + "Could not log in to Huum with given credentials" + ) from err diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 82b863e4e42..38001c58b35 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -1,7 +1,7 @@ { "domain": "huum", "name": "Huum", - "codeowners": ["@frwickst"], + "codeowners": ["@frwickst", "@vincentwolsink"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py index 443cbd52c36..d280bab6a59 100644 --- a/tests/components/huum/__init__.py +++ b/tests/components/huum/__init__.py @@ -1 +1,18 @@ """Tests for the huum integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Huum integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.huum.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 00000000000..023abd4429e --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,72 @@ +"""Configuration for Huum tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from huum.const import SaunaStatus +import pytest + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_huum() -> Generator[AsyncMock]: + """Mock data from the API.""" + huum = AsyncMock() + with ( + patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.turn_on", + return_value=huum, + ) as turn_on, + ): + huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.door_closed = True + huum.temperature = 30 + huum.sauna_name = 123456 + huum.target_temperature = 80 + huum.light = 1 + huum.humidity = 5 + huum.sauna_config.child_lock = "OFF" + huum.sauna_config.max_heating_time = 3 + huum.sauna_config.min_heating_time = 0 + huum.sauna_config.max_temp = 110 + huum.sauna_config.min_temp = 40 + huum.sauna_config.max_timer = 0 + huum.sauna_config.min_timer = 0 + huum.turn_on = turn_on + + yield huum + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.huum.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "huum@sauna.org", + CONF_PASSWORD: "ukuuku", + }, + unique_id="123456", + entry_id="AABBCC112233", + ) diff --git a/tests/components/huum/snapshots/test_climate.ambr b/tests/components/huum/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f18fd279f25 --- /dev/null +++ b/tests/components/huum/snapshots/test_climate.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_climate_entity[climate.huum_sauna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 110, + 'min_temp': 40, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.huum_sauna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator-off', + 'original_name': None, + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity[climate.huum_sauna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Huum sauna', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator-off', + 'max_temp': 110, + 'min_temp': 40, + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.huum_sauna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py new file mode 100644 index 00000000000..ca7fcf81185 --- /dev/null +++ b/tests/components/huum/test_climate.py @@ -0,0 +1,78 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.huum_sauna" + + +async def test_climate_entity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + mock_huum.turn_on.assert_called_once() + + +async def test_set_temperature( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(60) diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py index 9917f71fc08..d59eac51207 100644 --- a/tests/components/huum/test_config_flow.py +++ b/tests/components/huum/test_config_flow.py @@ -1,6 +1,6 @@ """Test the huum config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from huum.exceptions import Forbidden import pytest @@ -13,11 +13,13 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_USERNAME = "test-username" -TEST_PASSWORD = "test-password" +TEST_USERNAME = "huum@sauna.org" +TEST_PASSWORD = "ukuuku" -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_huum: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -26,24 +28,14 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_USERNAME @@ -54,42 +46,28 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: +async def test_signup_flow_already_set_up( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test that we handle already existing entities with same id.""" - mock_config_entry = MockConfigEntry( - title="Huum Sauna", - domain=DOMAIN, - unique_id=TEST_USERNAME, - data={ - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT @pytest.mark.parametrize( @@ -103,7 +81,11 @@ async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: ], ) async def test_huum_errors( - hass: HomeAssistant, raises: Exception, error_base: str + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + raises: Exception, + error_base: str, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -125,21 +107,11 @@ async def test_huum_errors( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error_base} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/huum/test_init.py b/tests/components/huum/test_init.py new file mode 100644 index 00000000000..fac5fa875ee --- /dev/null +++ b/tests/components/huum/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the Huum __init__.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_huum: AsyncMock +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED From ee35fc495d45d00895a666cb7b637aba1ac7883d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:44:37 +0200 Subject: [PATCH 0321/1118] Improve derivative sensor tests (#148941) --- tests/components/derivative/test_sensor.py | 117 +++++++++++++++------ 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 10092e30ca0..ee458ea54cd 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -27,8 +27,25 @@ from tests.common import ( mock_restore_cache_with_extra_data, ) +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} -async def test_state(hass: HomeAssistant) -> None: + +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2], + ], +) +async def test_state( + hass: HomeAssistant, + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state.""" config = { "sensor": { @@ -45,12 +62,13 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + for extra_attributes in attributes: + hass.states.async_set( + entity_id, 1, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -61,7 +79,24 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" -async def test_no_change(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1, A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2, A1, A2], + ], +) +async def test_no_change( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" config = { "sensor": { @@ -71,6 +106,7 @@ async def test_no_change(hass: HomeAssistant) -> None: "unit": "kW", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) @@ -78,20 +114,13 @@ async def test_no_change(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() + for value, extra_attributes in zip([0, 1, 1, 1], attributes, strict=True): + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -138,7 +167,7 @@ async def setup_tests( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -213,7 +242,24 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) -async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1] * 10 + [A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2] * 10 + [A1], + ], +) +async def test_data_moving_average_with_zeroes( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test that zeroes are properly handled within the time window.""" # We simulate the following situation: # The temperature rises 1 °C per minute for 10 minutes long. Then, it @@ -235,16 +281,21 @@ async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: "time_window": {"seconds": time_window}, "unit_time": UnitOfTime.MINUTES, "round": 1, - }, + } + | extra_config, ) base = dt_util.utcnow() with freeze_time(base) as freezer: last_derivative = 0 - for time, value in zip(times, temperature_values, strict=True): + for time, value, extra_attributes in zip( + times, temperature_values, attributes, strict=True + ): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}) + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -273,7 +324,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for temperature in range(30): temperature_values += [temperature] * 2 # two values per minute time_window = 600 - times = list(range(0, 1800 + 30, 30)) + times = list(range(0, 1800, 30)) config, entity_id = await _setup_sensor( hass, @@ -286,7 +337,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -330,7 +381,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -368,7 +419,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -506,7 +557,7 @@ async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: base = dt_util.utcnow() with freeze_time(base) as freezer: last_state_change = None - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) @@ -636,7 +687,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: actual_times = [] actual_values = [] with freeze_time(base_time) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): current_time = base_time + timedelta(seconds=time) freezer.move_to(current_time) hass.states.async_set( @@ -724,7 +775,7 @@ async def test_unavailable( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value, expect in zip(times, values, expected_state, strict=False): + for time, value, expect in zip(times, values, expected_state, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -759,7 +810,7 @@ async def test_unavailable_2( base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() From 9df97fb2e20f896d48fe4b209752ffc5f15aab40 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:31:55 +0200 Subject: [PATCH 0322/1118] Add correct labels for dependabot PRs (#148944) --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a394d7dcbba..f9bfa9b406d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,6 @@ updates: interval: daily time: "06:00" open-pull-requests-limit: 10 + labels: + - dependency + - github_actions From b33a556ca5699bdb5b9221558c689f7d9e934ab3 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 15:20:03 +0200 Subject: [PATCH 0323/1118] Bump zwave-js-server-python to 0.66.0 (#148939) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/triggers/event.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 4 ++-- tests/components/zwave_js/test_repairs.py | 2 +- tests/components/zwave_js/test_sensor.py | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 93d585d72a2..4c9ef784077 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.66.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 8d0ccf60fdf..52c24055052 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic.v1 import ValidationError +from pydantic import ValidationError import voluptuous as vol from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver @@ -78,7 +78,7 @@ def validate_event_data(obj: dict) -> dict: except ValidationError as exc: # Filter out required field errors if keys can be missing, and if there are # still errors, raise an exception - if [error for error in exc.errors() if error["type"] != "value_error.missing"]: + if [error for error in exc.errors() if error["type"] != "missing"]: raise vol.MultipleInvalid from exc return obj diff --git a/requirements_all.txt b/requirements_all.txt index 9267aa3f2bd..0203edd6aa5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3209,7 +3209,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b41f72e888..bc30c59da4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2644,7 +2644,7 @@ zeversolar==0.3.2 zha==0.0.62 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index bac0162ba74..6359f4bf5e7 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2520,7 +2520,7 @@ async def test_subscribe_rebuild_routes_progress( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) @@ -2564,7 +2564,7 @@ async def test_subscribe_rebuild_routes_progress_initial_value( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d8c3de92b3b..d783e3deaba 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -34,7 +34,7 @@ async def _trigger_repair_issue( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) with patch( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index a005d374b31..140d584f76f 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -247,7 +247,7 @@ async def test_invalid_multilevel_sensor_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -610,7 +610,7 @@ async def test_invalid_meter_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) From 40cabc8d7059809e8f4983e7f069ca2d85099785 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 17 Jul 2025 07:27:41 -0700 Subject: [PATCH 0324/1118] Validate min/max for input_text config (#148909) --- .../components/input_text/__init__.py | 17 +++++++++++---- tests/components/input_text/test_init.py | 21 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 998bf35cd82..4928b4325d1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, + MAX_LENGTH_STATE_STATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -51,8 +52,12 @@ STORAGE_VERSION = 1 STORAGE_FIELDS: VolDictType = { vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL, ""): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -84,8 +89,12 @@ CONFIG_SCHEMA = vol.Schema( lambda value: value or {}, { vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 2ca1d39a983..c0c18a5153c 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -81,16 +81,21 @@ async def async_set_value(hass: HomeAssistant, entity_id: str, value: str) -> No ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, - {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 51, "max": 50}}, + {"test_1": {"min": -1, "max": 100}}, + {"test_1": {"min": 0, "max": 256}}, + {"test_1": {"min": 0, "max": 3, "initial": "aaaaa"}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant) -> None: From 17920b6ec312f9c7c312a8133519cb6fe4a2a3dd Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Thu, 17 Jul 2025 16:34:15 +0200 Subject: [PATCH 0325/1118] Use climate min/max temp from sauna configuration in Huum (#148955) --- homeassistant/components/huum/climate.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index b0d36a56a46..c82fd2c91a5 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -46,8 +46,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_max_temp = 110 - _attr_min_temp = 40 _attr_has_entity_name = True _attr_name = None @@ -63,6 +61,16 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): model="UKU WiFi", ) + @property + def min_temp(self) -> int: + """Return configured minimal temperature.""" + return self.coordinator.data.sauna_config.min_temp + + @property + def max_temp(self) -> int: + """Return configured maximum temperature.""" + return self.coordinator.data.sauna_config.max_temp + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" From a96e38871f4c197c147a229a4758776e21c8fe8e Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 17:49:34 +0200 Subject: [PATCH 0326/1118] Z-Wave JS: Simplify strings for RSSI sensors (#148936) --- homeassistant/components/zwave_js/sensor.py | 18 +++++++------- .../components/zwave_js/strings.json | 16 ++++++------- tests/components/zwave_js/test_sensor.py | 24 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f62e6e1a9f2..df0a701bf15 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -421,7 +421,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -429,7 +429,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -438,7 +438,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -446,7 +446,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -455,7 +455,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -463,7 +463,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -472,7 +472,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_3.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "3"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -480,7 +480,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_3.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "3"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -549,7 +549,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="rssi", - translation_key="rssi", + translation_key="signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 63dad248246..7f59e640ef8 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -199,8 +199,8 @@ } }, "sensor": { - "average_background_rssi": { - "name": "Average background RSSI (channel {channel})" + "avg_signal_noise": { + "name": "Avg. signal noise (channel {channel})" }, "can": { "name": "Collisions" @@ -216,9 +216,6 @@ "unresponsive": "Unresponsive" } }, - "current_background_rssi": { - "name": "Current background RSSI (channel {channel})" - }, "last_seen": { "name": "Last seen" }, @@ -238,12 +235,15 @@ "unknown": "Unknown" } }, - "rssi": { - "name": "RSSI" - }, "rtt": { "name": "Round trip time" }, + "signal_noise": { + "name": "Signal noise (channel {channel})" + }, + "signal_strength": { + "name": "Signal strength" + }, "successful_commands": { "name": "Successful commands ({direction})" }, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 140d584f76f..42e2108be89 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -796,14 +796,14 @@ CONTROLLER_STATISTICS_SUFFIXES = { } # controller statistics with initial state of unknown CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { - "current_background_rssi_channel_0": -1, - "average_background_rssi_channel_0": -2, - "current_background_rssi_channel_1": -3, - "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": -5, - "average_background_rssi_channel_2": -6, - "current_background_rssi_channel_3": STATE_UNKNOWN, - "average_background_rssi_channel_3": STATE_UNKNOWN, + "signal_noise_channel_0": -1, + "avg_signal_noise_channel_0": -2, + "signal_noise_channel_1": -3, + "avg_signal_noise_channel_1": -4, + "signal_noise_channel_2": -5, + "avg_signal_noise_channel_2": -6, + "signal_noise_channel_3": STATE_UNKNOWN, + "avg_signal_noise_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -817,7 +817,7 @@ NODE_STATISTICS_SUFFIXES = { # node statistics with initial state of unknown NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, - "rssi": 7, + "signal_strength": 7, } @@ -887,7 +887,7 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -913,12 +913,12 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert not entry.disabled assert entry.disabled_by is None state = hass.states.get(entry.entity_id) - assert state + assert state, f"State for {entry.entity_id} not found" assert state.state == initial_state # Fire statistics updated for controller From fb13c8f4f2754eecfda48cb6df7d3874b016929a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 17 Jul 2025 19:34:58 +0200 Subject: [PATCH 0327/1118] Update arcam to 1.8.2 (#148956) --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 41396eca5d6..eb8764e1596 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.1"], + "requirements": ["arcam-fmj==1.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 0203edd6aa5..9f33d8a6dc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -513,7 +513,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc30c59da4e..6ac01ad70b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -483,7 +483,7 @@ apsystems-ez1==2.7.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 9802441feae751ea958bc2ee55bbd0753d358287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 17 Jul 2025 18:47:00 +0100 Subject: [PATCH 0328/1118] Bump hass-nabucasa from 0.106.0 to 0.107.1 (#148949) --- homeassistant/components/cloud/client.py | 3 ++- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 4 ++++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/snapshots/test_http_api.ambr | 2 +- tests/components/cloud/test_http_api.py | 2 +- 10 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index a857185f07f..e15ea92dece 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -40,10 +40,11 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "connection_error", "no_subscription", - "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", "subscription_expired", + "warn_bad_custom_domain_configuration", } diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7c64100873c..642bece1b8e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.106.0"], + "requirements": ["hass-nabucasa==0.107.1"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e7d219ff69e..193d9e3f948 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "connection_error": { + "title": "No connection", + "description": "You do not have a connection to Home Assistant Cloud. Check your network." + }, "no_subscription": { "title": "No subscription detected", "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f56c44d494a..ecbb7035ea9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.2 diff --git a/pyproject.toml b/pyproject.toml index 6946993e6af..3b0994ff2cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.106.0", + "hass-nabucasa==0.107.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 896ff44a3c7..ed9c100fd3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9f33d8a6dc5..6e1d211b75b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ac01ad70b0..c420331c46a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index c67691dfa1a..52c544dc541 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -37,7 +37,7 @@ google_enabled | False cloud_ice_servers_enabled | True remote_server | us-west-1 - certificate_status | CertificateStatus.READY + certificate_status | ready instance_id | 12345678901234567890 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 84630bc0320..f125a5cbdae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1050,7 +1050,7 @@ async def test_websocket_subscription_not_logged_in( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.cloud_api.async_subscription_info", + "hass_nabucasa.payments_api.PaymentsApi.subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) From 0d819f2389cdc04fe8cc0f3fd11112d833b3e8a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 20:30:40 +0200 Subject: [PATCH 0329/1118] Refactor WAQI tests (#148968) --- homeassistant/components/waqi/config_flow.py | 90 ++- tests/components/waqi/__init__.py | 12 + tests/components/waqi/conftest.py | 31 +- .../waqi/snapshots/test_sensor.ambr | 666 +++++++++++++++--- tests/components/waqi/test_config_flow.py | 222 ++---- tests/components/waqi/test_init.py | 24 + tests/components/waqi/test_sensor.py | 48 +- 7 files changed, 735 insertions(+), 358 deletions(-) create mode 100644 tests/components/waqi/test_init.py diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 51ba801c92e..8ed2dcd8425 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -66,24 +66,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(user_input[CONF_API_KEY]) - try: - await waqi_client.get_by_ip() - except WAQIAuthenticationError: - errors["base"] = "invalid_auth" - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = user_input - if user_input[CONF_METHOD] == CONF_MAP: - return await self.async_step_map() - return await self.async_step_station_number() + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(user_input[CONF_API_KEY]) + try: + await client.get_by_ip() + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", @@ -107,22 +105,20 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - try: - measuring_station = await waqi_client.get_by_coordinates( - user_input[CONF_LOCATION][CONF_LATITUDE], - user_input[CONF_LOCATION][CONF_LONGITUDE], - ) - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return await self._async_create_entry(measuring_station) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_MAP, data_schema=self.add_suggested_values_to_schema( @@ -149,21 +145,19 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via station number.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - station_number = user_input[CONF_STATION_NUMBER] - measuring_station, errors = await get_by_station_number( - waqi_client, abs(station_number) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + client, + abs(station_number) - station_number - station_number, ) - if not measuring_station: - measuring_station, _ = await get_by_station_number( - waqi_client, - abs(station_number) - station_number - station_number, - ) - if measuring_station: - return await self._async_create_entry(measuring_station) + if measuring_station: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_STATION_NUMBER, data_schema=vol.Schema( diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py index b6f36680ee3..be808875df8 100644 --- a/tests/components/waqi/__init__.py +++ b/tests/components/waqi/__init__.py @@ -1 +1,13 @@ """Tests for the World Air Quality Index (WAQI) integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py index 75709d4f56e..bb64fdef097 100644 --- a/tests/components/waqi/conftest.py +++ b/tests/components/waqi/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the World Air Quality Index (WAQI) tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, patch +from aiowaqi import WAQIAirQuality import pytest from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -29,3 +31,28 @@ def mock_config_entry() -> MockConfigEntry: title="de Jongweg, Utrecht", data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, ) + + +@pytest.fixture +async def mock_waqi(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock WAQI client.""" + with ( + patch( + "homeassistant.components.waqi.WAQIClient", + autospec=True, + ) as mock_waqi, + patch( + "homeassistant.components.waqi.config_flow.WAQIClient", + new=mock_waqi, + ), + ): + client = mock_waqi.return_value + air_quality = WAQIAirQuality.from_dict( + await async_load_json_object_fixture( + hass, "air_quality_sensor.json", DOMAIN + ) + ) + client.get_by_station_number.return_value = air_quality + client.get_by_ip.return_value = air_quality + client.get_by_coordinates.return_value = air_quality + yield client diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 08e58a74524..d0c46346b2e 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -1,5 +1,42 @@ # serializer version: 1 -# name: test_sensor +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-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.de_jongweg_utrecht_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_air_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -15,39 +52,104 @@ 'state': '29', }) # --- -# name: test_sensor.1 +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-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.de_jongweg_utrecht_carbon_monoxide', + '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': 'Carbon monoxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '4584_carbon_monoxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'device_class': 'humidity', - 'friendly_name': 'de Jongweg, Utrecht Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }) -# --- -# name: test_sensor.10 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '2.3', }) # --- -# name: test_sensor.11 +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dominant pollutant', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dominant_pollutant', + 'unique_id': '4584_dominant_pollutant', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -71,7 +173,309 @@ 'state': 'o3', }) # --- -# name: test_sensor.2 +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_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': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-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.de_jongweg_utrecht_nitrogen_dioxide', + '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': 'Nitrogen dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_dioxide', + 'unique_id': '4584_nitrogen_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-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.de_jongweg_utrecht_ozone', + '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': 'Ozone', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ozone', + 'unique_id': '4584_ozone', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-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.de_jongweg_utrecht_pm10', + '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': 'PM10', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm10', + 'unique_id': '4584_pm10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-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.de_jongweg_utrecht_pm2_5', + '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': 'PM2.5', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': '4584_pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-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.de_jongweg_utrecht_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -88,7 +492,99 @@ 'state': '1008.8', }) # --- -# name: test_sensor.3 +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-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.de_jongweg_utrecht_sulphur_dioxide', + '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': 'Sulphur dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sulphur_dioxide', + 'unique_id': '4584_sulphur_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -105,93 +601,55 @@ 'state': '16', }) # --- -# name: test_sensor.4 +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-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.de_jongweg_utrecht_visibility_using_nephelometry', + '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': 'Visibility using nephelometry', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'neph', + 'unique_id': '4584_neph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.5 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.6 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Ozone', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_ozone', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.4', - }) -# --- -# name: test_sensor.7 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.8 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM10', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '12', - }) -# --- -# name: test_sensor.9 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM2.5', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', + 'state': '80', }) # --- diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index a3fa47abc67..03759f96ff5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,15 +1,14 @@ """Test the World Air Quality Index (WAQI) config flow.""" -import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +from aiowaqi import WAQIAuthenticationError, WAQIConnectionError import pytest -from homeassistant import config_entries from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -20,10 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import async_load_fixture - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - @pytest.mark.parametrize( ("method", "payload"), @@ -45,63 +40,28 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_full_map_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" @@ -109,6 +69,7 @@ async def test_full_map_flow( CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584, } + assert result["result"].unique_id == "4584" assert len(mock_setup_entry.mock_calls) == 1 @@ -121,73 +82,43 @@ async def test_full_map_flow( ], ) async def test_flow_errors( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we handle errors during configuration.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - side_effect=exception, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "map" - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -232,6 +163,7 @@ async def test_flow_errors( async def test_error_in_second_step( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], exception: Exception, @@ -239,74 +171,36 @@ async def test_error_in_second_step( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), - patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = exception + mock_waqi.get_by_station_number.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = None + mock_waqi.get_by_station_number.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py new file mode 100644 index 00000000000..7e4487f8ad2 --- /dev/null +++ b/tests/components/waqi/test_init.py @@ -0,0 +1,24 @@ +"""Test the World Air Quality Index (WAQI) initialization.""" + +from unittest.mock import AsyncMock + +from aiowaqi import WAQIError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waqi: AsyncMock, +) -> None: + """Test setup failure due to API error.""" + mock_waqi.get_by_station_number.side_effect = WAQIError("API error") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7cd045604c8..d6e14d2dd54 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -1,59 +1,27 @@ """Test the World Air Quality Index (WAQI) sensor.""" -import json -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.waqi.const import DOMAIN -from homeassistant.components.waqi.sensor import SENSORS -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_load_fixture +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_waqi: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - for sensor in SENSORS: - entity_id = entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" - ) - assert hass.states.get(entity_id) == snapshot + """Test the World Air Quality Index (WAQI) sensor.""" + await setup_integration(hass, mock_config_entry) - -async def test_updating_failed( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - side_effect=WAQIError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 3b6eb045c67b095eca7cd2ddfeb8e75cee8bd442 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Thu, 17 Jul 2025 21:19:47 +0200 Subject: [PATCH 0330/1118] Bump async-upnp-client to 0.45.0 (#148961) --- 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 119d1d31d52..eac8ddcf713 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.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.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 0289d5100d6..4a73bf779e0 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.44.0"], + "requirements": ["async-upnp-client==0.45.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 a2ab8e6e466..1b927757a39 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.44.0" + "async-upnp-client==0.45.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 93943b0a9ea..2471e45b4e0 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.44.0"] + "requirements": ["async-upnp-client==0.45.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 62ee4ede7d9..825c5774c1d 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.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.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 07970cb25ca..d65ebb3a25a 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.44.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ecbb7035ea9..d26705842e2 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.44.0 +async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 attrs==25.3.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 6e1d211b75b..dce942e705c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,7 +527,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c420331c46a..b88311f6169 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -491,7 +491,7 @@ arcam-fmj==1.8.2 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 From 29afa891ecfb463e1ca73d14d60ceb3eb9dc6323 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 17 Jul 2025 23:06:47 +0200 Subject: [PATCH 0331/1118] Add YAML and discovery info export feature for MQTT device subentries (#141896) Co-authored-by: Norbert Rittel --- homeassistant/components/mqtt/config_flow.py | 128 ++++++++++++- homeassistant/components/mqtt/entity.py | 76 +++++++- homeassistant/components/mqtt/repairs.py | 74 ++++++++ homeassistant/components/mqtt/strings.json | 59 +++++- tests/components/mqtt/test_config_flow.py | 100 +++++++++++ tests/components/mqtt/test_repairs.py | 179 +++++++++++++++++++ 6 files changed, 608 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/mqtt/repairs.py create mode 100644 tests/components/mqtt/test_repairs.py diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a3cf2d1d12f..52f00c82c27 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass from enum import IntEnum +import json import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError @@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.serialization import ( ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +import yaml from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass @@ -78,6 +80,7 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_STATE_TEMPLATE, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -321,6 +324,10 @@ SET_CLIENT_CERT = "set_client_cert" BOOLEAN_SELECTOR = BooleanSelector() TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +TEXT_SELECTOR_READ_ONLY = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True) +) +URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL)) PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) PORT_SELECTOR = vol.All( NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), @@ -400,6 +407,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( ) ) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True)) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( { @@ -556,6 +564,8 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( ) ) +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} + @callback def validate_cover_platform_config( @@ -3102,8 +3112,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) self._async_update_component_data_defaults() - if self._subentry_data != self._get_reconfigure_subentry().data: - menu_options.append("save_changes") + menu_options.append( + "save_changes" + if self._subentry_data != self._get_reconfigure_subentry().data + else "export" + ) return self.async_show_menu( step_id="summary_menu", menu_options=menu_options, @@ -3145,6 +3158,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): title=self._subentry_data[CONF_DEVICE][CONF_NAME], ) + async def async_step_export( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML or discovery payload.""" + return self.async_show_menu( + step_id="export", + menu_options=["export_yaml", "export_discovery"], + ) + + async def async_step_export_yaml( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML.""" + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + mqtt_yaml_config_base: dict[str, list[dict[str, dict[str, Any]]]] = {DOMAIN: []} + mqtt_yaml_config = mqtt_yaml_config_base[DOMAIN] + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config[CONF_DEVICE] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + platform = component_config.pop(CONF_PLATFORM) + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + mqtt_yaml_config.append({platform: component_config}) + + yaml_config = yaml.dump(mqtt_yaml_config_base) + data_schema = vol.Schema( + { + vol.Optional("yaml"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={"yaml": yaml_config}, + ) + return self.async_show_form( + step_id="export_yaml", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + + async def async_step_export_discovery( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config dor MQTT discovery.""" + + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + discovery_topic = f"homeassistant/device/{subentry.subentry_id}/config" + discovery_payload: dict[str, Any] = {} + discovery_payload.update(self._subentry_data.get("availability", {})) + discovery_payload["dev"] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + discovery_payload["o"] = {"name": "MQTT subentry export"} + discovery_payload["cmps"] = {} + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + discovery_payload["cmps"][component_id] = component_config + + data_schema = vol.Schema( + { + vol.Optional("discovery_topic"): TEXT_SELECTOR_READ_ONLY, + vol.Optional("discovery_payload"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={ + "discovery_topic": discovery_topic, + "discovery_payload": json.dumps(discovery_payload, indent=2), + }, + ) + return self.async_show_form( + step_id="export_discovery", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + @callback def async_is_pem_data(data: bytes) -> bool: diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f1594a7b034..f0e7f915551 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -247,6 +247,58 @@ def async_setup_entity_entry_helper( """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = hass.data[DATA_MQTT] + @callback + def _async_migrate_subentry( + config: dict[str, Any], raw_config: dict[str, Any], migration_type: str + ) -> bool: + """Start a repair flow to allow migration of MQTT device subentries. + + If a YAML config or discovery is detected using the ID + of an existing mqtt subentry, and exported configuration is detected, + and a repair flow is offered to migrate the subentry. + """ + if ( + CONF_DEVICE in config + and CONF_IDENTIFIERS in config[CONF_DEVICE] + and config[CONF_DEVICE][CONF_IDENTIFIERS] + and (subentry_id := config[CONF_DEVICE][CONF_IDENTIFIERS][0]) + in entry.subentries + ): + name: str = config[CONF_DEVICE].get(CONF_NAME, "-") + if migration_type == "subentry_migration_yaml": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to YAML config: %s", + subentry_id, + raw_config, + ) + elif migration_type == "subentry_migration_discovery": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to configuration via MQTT discovery: %s", + subentry_id, + raw_config, + ) + async_create_issue( + hass, + DOMAIN, + subentry_id, + issue_domain=DOMAIN, + is_fixable=True, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(domain), + data={ + "entry_id": entry.entry_id, + "subentry_id": subentry_id, + "name": name, + }, + translation_placeholders={"name": name}, + translation_key=migration_type, + ) + return True + + return False + @callback def _async_setup_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, @@ -263,9 +315,22 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None - async_add_entities( - [entity_class(hass, config, entry, discovery_payload.discovery_data)] - ) + if _async_migrate_subentry( + config, discovery_payload, "subentry_migration_discovery" + ): + _handle_discovery_failure(hass, discovery_payload) + _LOGGER.debug( + "MQTT discovery skipped, as device exists in subentry, " + "and repair flow must be completed first" + ) + else: + async_add_entities( + [ + entity_class( + hass, config, entry, discovery_payload.discovery_data + ) + ] + ) except vol.Invalid as err: _handle_discovery_failure(hass, discovery_payload) async_handle_schema_error(discovery_payload, err) @@ -346,6 +411,11 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None + if _async_migrate_subentry( + config, yaml_config, "subentry_migration_yaml" + ): + continue + entities.append(entity_class(hass, config, entry, None)) except vol.Invalid as exc: error = str(exc) diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py new file mode 100644 index 00000000000..6a002904f11 --- /dev/null +++ b/homeassistant/components/mqtt/repairs.py @@ -0,0 +1,74 @@ +"""Repairs for MQTT.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + + +class MQTTDeviceEntryMigration(RepairsFlow): + """Handler to remove subentry for migrated MQTT device.""" + + def __init__(self, entry_id: str, subentry_id: str, name: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.subentry_id = subentry_id + self.name = name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + device_registry = dr.async_get(self.hass) + subentry_device = device_registry.async_get_device( + identifiers={(DOMAIN, self.subentry_id)} + ) + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + assert subentry_device is not None + self.hass.config_entries.async_remove_subentry(entry, self.subentry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"name": self.name}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert data is not None + entry_id = data["entry_id"] + subentry_id = data["subentry_id"] + name = data["name"] + if TYPE_CHECKING: + assert isinstance(entry_id, str) + assert isinstance(subentry_id, str) + assert isinstance(name, str) + return MQTTDeviceEntryMigration( + entry_id=entry_id, + subentry_id=subentry_id, + name=name, + ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 96b5bd15d28..1315463ebcf 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,28 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "subentry_migration_discovery": { + "title": "MQTT device \"{name}\" subentry migration to MQTT discovery", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_discovery::title%]", + "description": "Exported MQTT device \"{name}\" identified via MQTT discovery. Select **Submit** to confirm that the MQTT device is to be migrated to the main MQTT configuration, and to remove the existing MQTT device subentry. Make sure that the discovery is retained at the MQTT broker, or is resent after the subentry is removed, so that the MQTT device will be set up correctly. As an alternative you can change the device identifiers and entity unique ID-s in your MQTT discovery configuration payload, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } + }, + "subentry_migration_yaml": { + "title": "MQTT device \"{name}\" subentry migration to YAML", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_yaml::title%]", + "description": "Exported MQTT device \"{name}\" identified in YAML configuration. Select **Submit** to confirm that the MQTT device is to be migrated to main MQTT config entry, and to remove the existing MQTT device subentry. As an alternative you can change the device identifiers and entity unique ID-s in your configuration.yaml file, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } } }, "config": { @@ -107,10 +129,10 @@ "config_subentries": { "device": { "initiate_flow": { - "user": "Add MQTT Device", - "reconfigure": "Reconfigure MQTT Device" + "user": "Add MQTT device", + "reconfigure": "Reconfigure MQTT device" }, - "entry_type": "MQTT Device", + "entry_type": "MQTT device", "step": { "availability": { "title": "Availability options", @@ -175,6 +197,7 @@ "delete_entity": "Delete an entity", "availability": "Configure availability", "device": "Update device properties", + "export": "Export MQTT device configuration", "save_changes": "Save changes" } }, @@ -627,6 +650,36 @@ } } } + }, + "export": { + "title": "Export MQTT device config", + "description": "An export allows you to migrate the MQTT device configuration to YAML-based configuration or MQTT discovery. The configuration export can also be helpful for troubleshooting.", + "menu_options": { + "export_discovery": "Export MQTT discovery information", + "export_yaml": "Export to YAML configuration" + } + }, + "export_yaml": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the setup of the MQTT device was tried via YAML instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "yaml": "Copy the YAML configuration below:" + }, + "data_description": { + "yaml": "Place YAML configuration in your [configuration.yaml]({url}#yaml-configuration-listed-per-item)." + } + }, + "export_discovery": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "To allow setup via MQTT [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the information from the fields below. Home Assistant will detect if the setup of the MQTT device was tried via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "discovery_topic": "Discovery topic", + "discovery_payload": "Discovery payload:" + }, + "data_description": { + "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", + "discovery_payload": "The JSON [discovery payload]({url}#discovery-discovery-payload) that contains information about the MQTT device." + } } }, "abort": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 77c74001939..ce0a0c44a79 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3344,6 +3344,7 @@ async def test_subentry_reconfigure_remove_entity( "delete_entity", "device", "availability", + "export", ] # assert we can delete an entity @@ -3465,6 +3466,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "delete_entity", "device", "availability", + "export", ] # assert we can update an entity @@ -3683,6 +3685,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3823,6 +3826,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3953,6 +3957,7 @@ async def test_subentry_reconfigure_add_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -4058,6 +4063,7 @@ async def test_subentry_reconfigure_update_device_properties( "delete_entity", "device", "availability", + "export", ] # assert we can update the device properties @@ -4214,6 +4220,100 @@ async def test_subentry_reconfigure_availablity( } +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "field_suggestions"), + [ + ("export_yaml", {"yaml": "identifiers:\n - {}\n"}), + ( + "export_discovery", + { + "discovery_topic": "homeassistant/device/{}/config", + "discovery_payload": '"identifiers": [\n "{}"\n', + }, + ), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + flow_step: str, + field_suggestions: dict[str, str], +) -> None: + """Test the subentry ConfigFlow reconfigure export feature.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Assert the export is correct + for field in result["data_schema"].schema: + assert ( + field_suggestions[field].format(subentry_id) + in field.description["suggested_value"] + ) + + # Back to summary menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + async def test_subentry_configflow_section_feature( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py new file mode 100644 index 00000000000..bc7b9dd4294 --- /dev/null +++ b/tests/components/mqtt/test_repairs.py @@ -0,0 +1,179 @@ +"""Test repairs for MQTT.""" + +from collections.abc import Coroutine +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.util.yaml import parse_yaml + +from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message + +from tests.common import MockConfigEntry, async_capture_events +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.conftest import ClientSessionGenerator +from tests.typing import MqttMockHAClientGenerator + + +async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + with patch( + "homeassistant.config.load_yaml_config_file", + return_value=parse_yaml(config["yaml"]), + ): + await hass.services.async_call( + mqtt.DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def help_setup_discovery(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + async_fire_mqtt_message( + hass, config["discovery_topic"], config["discovery_payload"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "setup_helper", "translation_key"), + [ + ("export_yaml", help_setup_yaml, "subentry_migration_yaml"), + ("export_discovery", help_setup_discovery, "subentry_migration_discovery"), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + hass_client: ClientSessionGenerator, + flow_step: str, + setup_helper: Coroutine[Any, Any, None], + translation_key: str, +) -> None: + """Test the subentry ConfigFlow YAML export with migration to YAML.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Copy the exported config suggested values for an export + suggested_values_from_schema = { + field: field.description["suggested_value"] + for field in result["data_schema"].schema + } + # Try to set up the exported config with a changed device name + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + + # Assert the subentry device was not effected by the exported configs + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # Assert a repair flow was created + # This happens when the exported device identifier was detected + # The subentry ID is used as device identifier + assert len(events) == 1 + issue_id = events[0].data["issue_id"] + issue_registry = ir.async_get(hass) + repair_issue = issue_registry.async_get_issue(mqtt.DOMAIN, issue_id) + assert repair_issue.translation_key == translation_key + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"name": "Milk notifier"} + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "create_entry" + + # Assert the subentry is removed and no other entity has linked the device + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is None + + await hass.async_block_till_done(wait_background_tasks=True) + assert len(config_entry.subentries) == 0 + + # Try to set up the exported config again + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + assert len(events) == 0 + + # The MQTT device was now set up from the new source + await hass.async_block_till_done(wait_background_tasks=True) + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {None} + assert device is not None From c0744537635901c8802a5fc6137e0337d9de8ad9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:13:46 +0200 Subject: [PATCH 0332/1118] Remove obsolete variables in WAQI (#148975) --- homeassistant/components/waqi/sensor.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 59daf60392e..7f249b059a3 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from aiowaqi import WAQIAirQuality from aiowaqi.models import Pollutant @@ -26,17 +25,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import WAQIDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - -ATTR_DOMINENTPOL = "dominentpol" -ATTR_HUMIDITY = "humidity" -ATTR_NITROGEN_DIOXIDE = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM10 = "pm_10" -ATTR_PM2_5 = "pm_2_5" -ATTR_PRESSURE = "pressure" -ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" - @dataclass(frozen=True, kw_only=True) class WAQISensorEntityDescription(SensorEntityDescription): From aacaa9a20f6d97bcd64d8e9e0a44b75cd7e38cb1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:14:19 +0200 Subject: [PATCH 0333/1118] Pass Syncthru entry to coordinator (#148974) --- homeassistant/components/syncthru/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 0b96b354436..27239a5a520 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -28,6 +28,7 @@ class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_interval=timedelta(seconds=30), ) self.syncthru = SyncThru( From 3c87a3e892511d31da99e4c38d26c9bb9befbc34 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:19:45 -0400 Subject: [PATCH 0334/1118] Add a preview to template config flow for alarm control panel, image, and select platforms (#148441) --- .../template/alarm_control_panel.py | 12 ++++++++++++ .../components/template/config_flow.py | 19 ++++++++++++++----- homeassistant/components/template/select.py | 9 +++++++++ .../template/test_alarm_control_panel.py | 19 ++++++++++++++++++- tests/components/template/test_select.py | 19 ++++++++++++++++++- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 97896e08a68..cd70a7d44e0 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -206,6 +206,18 @@ async def async_setup_platform( ) +@callback +def async_create_preview_alarm_control_panel( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateAlarmControlPanelEntity: + """Create a preview alarm control panel.""" + updated_config = rewrite_options_to_modern_conf(config) + validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA( + updated_config | {CONF_NAME: name} + ) + return StateAlarmControlPanelEntity(hass, validated_config, None) + + class AbstractTemplateAlarmControlPanel( AbstractTemplateEntity, AlarmControlPanelEntity, RestoreEntity ): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index e6cc377bc26..d6fc5768f81 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -50,6 +50,7 @@ from .alarm_control_panel import ( CONF_DISARM_ACTION, CONF_TRIGGER_ACTION, TemplateCodeFormat, + async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN @@ -63,7 +64,7 @@ from .number import ( DEFAULT_STEP, async_create_preview_number, ) -from .select import CONF_OPTIONS, CONF_SELECT_OPTION +from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_select from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity @@ -319,6 +320,7 @@ CONFIG_FLOW = { "user": SchemaFlowMenuStep(TEMPLATE_TYPES), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( config_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -332,6 +334,7 @@ CONFIG_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -341,6 +344,7 @@ CONFIG_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( config_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -360,6 +364,7 @@ OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( options_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -373,6 +378,7 @@ OPTIONS_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -382,6 +388,7 @@ OPTIONS_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( options_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -400,10 +407,12 @@ CREATE_PREVIEW_ENTITY: dict[ str, Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity], ] = { - "binary_sensor": async_create_preview_binary_sensor, - "number": async_create_preview_number, - "sensor": async_create_preview_sensor, - "switch": async_create_preview_switch, + Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, + Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.NUMBER: async_create_preview_number, + Platform.SELECT: async_create_preview_select, + Platform.SENSOR: async_create_preview_sensor, + Platform.SWITCH: async_create_preview_switch, } diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index d5abf7033a9..4273af6db28 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -90,6 +90,15 @@ async def async_setup_entry( async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) +@callback +def async_create_preview_select( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateSelect: + """Create a preview select.""" + validated_config = SELECT_CONFIG_SCHEMA(config | {CONF_NAME: name}) + return TemplateSelect(hass, validated_config, None) + + class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1984b4ea2af..06d678edcab 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -23,9 +23,10 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache +from tests.conftest import WebSocketGenerator TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" @@ -915,3 +916,19 @@ async def test_device_id( template_entity = entity_registry.async_get("alarm_control_panel.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + ALARM_DOMAIN, + {"name": "My template", "state": "{{ 'disarmed' }}"}, + ) + + assert state["state"] == AlarmControlPanelState.DISARMED diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 6971d41750d..f613fa865a6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -35,9 +35,10 @@ 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 .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.conftest import WebSocketGenerator _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" @@ -645,3 +646,19 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "yes" + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + select.DOMAIN, + {"name": "My template", **TEST_OPTIONS}, + ) + + assert state["state"] == "test" From 37a154b1dfb7d78f890e371868853cdd46339058 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:22:30 +0200 Subject: [PATCH 0335/1118] Migrate WAQI to runtime data (#148977) --- homeassistant/components/waqi/__init__.py | 15 +++++---------- homeassistant/components/waqi/coordinator.py | 6 ++++-- homeassistant/components/waqi/sensor.py | 7 +++---- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 9821b5435d9..7b1243ed905 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -4,18 +4,16 @@ from __future__ import annotations from aiowaqi import WAQIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" client = WAQIClient(session=async_get_clientsession(hass)) @@ -23,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + entry.runtime_data = waqi_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 86f553a86cd..f40df4a1b89 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -12,14 +12,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER +type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator] + class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): """The WAQI Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: WAQIConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 7f249b059a3..c887d893c08 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -127,11 +126,11 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WAQIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" - coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( WaqiSensor(coordinator, sensor) for sensor in SENSORS From 0ff0902ccf00c46c9df09aae6c7ccc296b6156b1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:36:18 +0200 Subject: [PATCH 0336/1118] Add icons to WAQI (#148976) --- homeassistant/components/waqi/icons.json | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 homeassistant/components/waqi/icons.json diff --git a/homeassistant/components/waqi/icons.json b/homeassistant/components/waqi/icons.json new file mode 100644 index 00000000000..545e49fd54e --- /dev/null +++ b/homeassistant/components/waqi/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "sulphur_dioxide": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "neph": { + "default": "mdi:eye" + }, + "dominant_pollutant": { + "default": "mdi:molecule", + "state": { + "co": "mdi:molecule-co", + "neph": "mdi:eye", + "no2": "mdi:molecule", + "o3": "mdi:molecule", + "so2": "mdi:molecule", + "pm10": "mdi:molecule", + "pm25": "mdi:molecule" + } + } + } + } +} From 6b959f42f61a8b005b3340adbae47f7a90101eae Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Fri, 18 Jul 2025 00:06:51 +0200 Subject: [PATCH 0337/1118] Introduce base entity for supporting multiple platforms in Huum (#148957) --- homeassistant/components/huum/climate.py | 13 ++----------- homeassistant/components/huum/entity.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/huum/entity.py diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index c82fd2c91a5..6a50137f0a7 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -16,12 +16,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity _LOGGER = logging.getLogger(__name__) @@ -35,7 +33,7 @@ async def async_setup_entry( async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): +class HuumDevice(HuumBaseEntity, ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -46,7 +44,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: @@ -54,12 +51,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - name="Huum sauna", - manufacturer="Huum", - model="UKU WiFi", - ) @property def min_temp(self) -> int: diff --git a/homeassistant/components/huum/entity.py b/homeassistant/components/huum/entity.py new file mode 100644 index 00000000000..cd30119f6fe --- /dev/null +++ b/homeassistant/components/huum/entity.py @@ -0,0 +1,24 @@ +"""Define Huum Base entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HuumDataUpdateCoordinator + + +class HuumBaseEntity(CoordinatorEntity[HuumDataUpdateCoordinator]): + """Huum base Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Huum sauna", + manufacturer="Huum", + model="UKU WiFi", + ) From 073ea813f0a36da5a82180e4a21c24f2a262749a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 18 Jul 2025 00:08:45 +0200 Subject: [PATCH 0338/1118] Update aioairzone-cloud to v0.6.15 (#148947) --- homeassistant/components/airzone_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/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 3a494aa361e..8694d3d06d9 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.14"] + "requirements": ["aioairzone-cloud==0.6.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index dce942e705c..85da7a1f7b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.14 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b88311f6169..5377eb55c3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.14 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 From 50688bbd69cfe8d9e373ccaba8aa305dd95932f9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 18 Jul 2025 05:49:27 +0200 Subject: [PATCH 0339/1118] Add support for calling tools in Open Router (#148881) --- .../components/open_router/config_flow.py | 30 +++- homeassistant/components/open_router/const.py | 12 ++ .../components/open_router/conversation.py | 142 +++++++++++++++--- .../components/open_router/strings.json | 8 +- tests/components/open_router/conftest.py | 30 +++- .../snapshots/test_conversation.ambr | 140 +++++++++++++++++ .../open_router/test_config_flow.py | 66 ++++++-- .../open_router/test_conversation.py | 120 ++++++++++++++- 8 files changed, 497 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index 48d37d79cc6..e228492e3a1 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -16,8 +16,9 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import callback +from homeassistant.helpers import llm from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -25,9 +26,10 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, ) -from .const import DOMAIN +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS _LOGGER = logging.getLogger(__name__) @@ -90,6 +92,8 @@ class ConversationFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """User flow to create a sensor subentry.""" if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) return self.async_create_entry( title=self.options[user_input[CONF_MODEL]], data=user_input ) @@ -99,11 +103,17 @@ class ConversationFlowHandler(ConfigSubentryFlow): api_key=entry.data[CONF_API_KEY], http_client=get_async_client(self.hass), ) + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] options = [] async for model in client.with_options(timeout=10.0).models.list(): options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined] self.options[model.id] = model.name # type: ignore[attr-defined] - return self.async_show_form( step_id="user", data_schema=vol.Schema( @@ -113,6 +123,20 @@ class ConversationFlowHandler(ConfigSubentryFlow): options=options, mode=SelectSelectorMode.DROPDOWN, sort=True ), ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": RECOMMENDED_CONVERSATION_OPTIONS[ + CONF_PROMPT + ] + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), } ), ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index e357f28d6d5..9fbce10da4e 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -2,5 +2,17 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "open_router" LOGGER = logging.getLogger(__package__) + +CONF_PROMPT = "prompt" +CONF_RECOMMENDED = "recommended" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index efc98835982..06196565aad 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -1,25 +1,39 @@ """Conversation support for OpenRouter.""" -from typing import Literal +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal import openai +from openai import NOT_GIVEN from openai.types.chat import ( ChatCompletionAssistantMessageParam, + ChatCompletionMessage, ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, ChatCompletionUserMessageParam, ) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenRouterConfigEntry -from .const import DOMAIN, LOGGER +from .const import CONF_PROMPT, DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( @@ -35,13 +49,31 @@ async def async_setup_entry( ) +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + + def _convert_content_to_chat_message( content: conversation.Content, ) -> ChatCompletionMessageParam | None: """Convert any native chat message for this agent to the native format.""" LOGGER.debug("_convert_content_to_chat_message=%s", content) if isinstance(content, conversation.ToolResultContent): - return None + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) role: Literal["user", "assistant", "system"] = content.role if role == "system" and content.content: @@ -51,13 +83,55 @@ def _convert_content_to_chat_message( return ChatCompletionUserMessageParam(role="user", content=content.content) if role == "assistant": - return ChatCompletionAssistantMessageParam( - role="assistant", content=content.content + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param LOGGER.warning("Could not convert message to Completions API: %s", content) return None +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenRouter message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + ] + yield data + + class OpenRouterConversationEntity(conversation.ConversationEntity): """OpenRouter conversation agent.""" @@ -75,6 +149,10 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): name=subentry.title, entry_type=DeviceEntryType.SERVICE, ) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) @property def supported_languages(self) -> list[str] | Literal["*"]: @@ -93,12 +171,19 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): await chat_log.async_provide_llm_data( user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), - None, + options.get(CONF_PROMPT), user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() + tools: list[ChatCompletionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + messages = [ m for content in chat_log.content @@ -107,27 +192,34 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): client = self.entry.runtime_data - try: - result = await client.chat.completions.create( - model=self.model, - messages=messages, - user=chat_log.conversation_id, - extra_headers={ - "X-Title": "Home Assistant", - "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", - }, - ) - except openai.OpenAIError as err: - LOGGER.error("Error talking to API: %s", err) - raise HomeAssistantError("Error talking to API") from err + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools or NOT_GIVEN, + user=chat_log.conversation_id, + extra_headers={ + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + ) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err - result_message = result.choices[0].message + result_message = result.choices[0].message - chat_log.async_add_assistant_content_without_tools( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=result_message.content, + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] ) - ) + if not chat_log.unresponded_tool_results: + break return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index 93936b4d92b..6e6674dac06 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -24,7 +24,13 @@ "user": { "description": "Configure the new conversation agent", "data": { - "model": "Model" + "model": "Model", + "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + }, + "data_description": { + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." } } }, diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index e2e0fbb2c37..ca679c2ebef 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from openai.types import CompletionUsage @@ -9,10 +10,11 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice import pytest -from homeassistant.components.open_router.const import DOMAIN +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN from homeassistant.config_entries import ConfigSubentryData -from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,7 +31,27 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def enable_assist() -> bool: + """Mock conversation subentry data.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, conversation_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( title="OpenRouter", @@ -39,7 +61,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, subentries_data=[ ConfigSubentryData( - data={CONF_MODEL: "gpt-3.5-turbo"}, + data=conversation_subentry_data, subentry_id="ABCDEF", subentry_type="conversation", title="GPT-3.5 Turbo", diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index 90f9097e854..d119c2f6aa5 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -1,4 +1,108 @@ # serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_default_prompt list([ dict({ @@ -14,3 +118,39 @@ }), ]) # --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 6be258dca38..5e7a67d4a2b 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import AsyncMock import pytest from python_open_router import OpenRouterError -from homeassistant.components.open_router.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigSubentry -from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -129,18 +129,56 @@ async def test_create_conversation_agent( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_MODEL: "gpt-3.5-turbo"}, + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - subentry_id = list(mock_config_entry.subentries)[0] - assert ( - ConfigSubentry( - data={CONF_MODEL: "gpt-3.5-turbo"}, - subentry_id=subentry_id, - subentry_type="conversation", - title="GPT-3.5 Turbo", - unique_id=None, - ) - in mock_config_entry.subentries.values() + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 043dae2ff30..84742191efd 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -3,16 +3,24 @@ from unittest.mock import AsyncMock from freezegun import freeze_time +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_tool_call import Function import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import area_registry as ar, device_registry as dr, intent +from homeassistant.helpers import entity_registry as er, intent from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 @@ -23,11 +31,23 @@ def freeze_the_time(): yield +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_default_prompt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, mock_openai_client: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 @@ -50,3 +70,95 @@ async def test_default_prompt( "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", "X-Title": "Home Assistant", } + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + async def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + mock_openai_client.chat.completions.create = completion_result + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot From 414057d455a48fcebbbeadce16a5ecc45bf82bb9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 18 Jul 2025 08:33:30 +0200 Subject: [PATCH 0340/1118] Add image platform to PlayStation Network (#148928) --- .../playstation_network/__init__.py | 1 + .../components/playstation_network/helpers.py | 6 +- .../components/playstation_network/icons.json | 8 ++ .../components/playstation_network/image.py | 105 ++++++++++++++++++ .../playstation_network/strings.json | 8 ++ .../playstation_network/conftest.py | 3 + .../snapshots/test_diagnostics.ambr | 3 + .../playstation_network/test_image.py | 96 ++++++++++++++++ 8 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/image.py create mode 100644 tests/components/playstation_network/test_image.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index e5b98d00726..be0eae961e0 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -16,6 +16,7 @@ from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.MEDIA_PLAYER, Platform.SENSOR, ] diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index debe7a338e2..f7f6143e94f 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -43,11 +43,14 @@ class PlaystationNetworkData: registered_platforms: set[PlatformType] = field(default_factory=set) trophy_summary: TrophySummary | None = None profile: dict[str, Any] = field(default_factory=dict) + shareable_profile_link: dict[str, str] = field(default_factory=dict) class PlaystationNetwork: """Helper Class to return playstation network data in an easy to use structure.""" + shareable_profile_link: dict[str, str] + def __init__(self, hass: HomeAssistant, npsso: str) -> None: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) @@ -63,6 +66,7 @@ class PlaystationNetwork: """Setup PSN.""" self.user = self.psn.user(online_id="me") self.client = self.psn.me() + self.shareable_profile_link = self.client.get_shareable_profile_link() self.trophy_titles = list(self.user.trophy_titles()) async def async_setup(self) -> None: @@ -100,7 +104,7 @@ class PlaystationNetwork: data = await self.hass.async_add_executor_job(self.retrieve_psn_data) data.username = self.user.online_id data.account_id = self.user.account_id - + data.shareable_profile_link = self.shareable_profile_link data.availability = data.presence["basicPresence"]["availability"] session = SessionData() diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2742ab1c989..2ea09823ca4 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -43,6 +43,14 @@ "offline": "mdi:account-off-outline" } } + }, + "image": { + "share_profile": { + "default": "mdi:share-variant" + }, + "avatar": { + "default": "mdi:account-circle" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py new file mode 100644 index 00000000000..8f9d19e3a55 --- /dev/null +++ b/homeassistant/components/playstation_network/image.py @@ -0,0 +1,105 @@ +"""Image platform for PlayStation Network.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 0 + + +class PlaystationNetworkImage(StrEnum): + """PlayStation Network images.""" + + AVATAR = "avatar" + SHARE_PROFILE = "share_profile" + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkImageEntityDescription(ImageEntityDescription): + """Image entity description.""" + + image_url_fn: Callable[[PlaystationNetworkData], str | None] + + +IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = ( + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.SHARE_PROFILE, + translation_key=PlaystationNetworkImage.SHARE_PROFILE, + image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"], + ), + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.AVATAR, + translation_key=PlaystationNetworkImage.AVATAR, + image_url_fn=( + lambda data: next( + ( + pic.get("url") + for pic in data.profile["avatars"] + if pic.get("size") == "xl" + ), + None, + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up image platform.""" + + coordinator = config_entry.runtime_data.user_data + + async_add_entities( + [ + PlaystationNetworkImageEntity(hass, coordinator, description) + for description in IMAGE_DESCRIPTIONS + ] + ) + + +class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity): + """An image entity.""" + + entity_description: PlaystationNetworkImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + coordinator: PlaystationNetworkUserDataCoordinator, + entity_description: PlaystationNetworkImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, entity_description) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.entity_description.image_url_fn(coordinator.data) + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + url = self.entity_description.image_url_fn(self.coordinator.data) + + if url != self._attr_image_url: + self._attr_image_url = url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 360687f97c8..aaefdf51506 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -96,6 +96,14 @@ "busy": "Away" } } + }, + "image": { + "share_profile": { + "name": "Share profile" + }, + "avatar": { + "name": "Avatar" + } } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 5f6f3436699..77ec2377932 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -156,6 +156,9 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: ] } } + client.me.return_value.get_shareable_profile_link.return_value = { + "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index ebf8d9e927f..0b7aa63fc03 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,6 +71,9 @@ 'PS5', 'PSVITA', ]), + 'shareable_profile_link': dict({ + 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', + }), 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ diff --git a/tests/components/playstation_network/test_image.py b/tests/components/playstation_network/test_image.py new file mode 100644 index 00000000000..0dc52646d9e --- /dev/null +++ b/tests/components/playstation_network/test_image.py @@ -0,0 +1,96 @@ +"""Test the PlayStation Network image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@respx.mock +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + mock_psnawpapi: MagicMock, +) -> None: + """Test image platform.""" + freezer.move_to("2025-06-16T00:00:00-00:00") + + respx.get( + "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png" + ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test") + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png" + profile = mock_psnawpapi.user.return_value.profile.return_value + profile["avatars"] = [{"size": "xl", "url": ava}] + mock_psnawpapi.user.return_value.profile.return_value = profile + respx.get(ava).respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:30+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test2" + assert resp.content_type == "image/png" + assert resp.content_length == 5 From 57c024449c97375e95c67e2c9b8c5813d0e8af5e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 18 Jul 2025 00:02:44 -0700 Subject: [PATCH 0341/1118] Fix broken invalid_config tests (#148965) --- tests/components/counter/test_init.py | 10 ++++++---- tests/components/input_boolean/test_init.py | 10 ++++++---- tests/components/input_button/test_init.py | 10 ++++++---- tests/components/input_number/test_init.py | 17 ++++++++++------- tests/components/input_select/test_init.py | 15 ++++++++------- tests/components/schedule/test_init.py | 11 +++-------- tests/components/timer/test_init.py | 7 +++---- 7 files changed, 42 insertions(+), 38 deletions(-) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index ef2caf2eab1..c5595d7fcbe 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -73,12 +73,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index b2e99836477..b82bbe59203 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -54,12 +54,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_methods(hass: HomeAssistant) -> None: diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index e59d0543751..78cfd0a3d8b 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -47,12 +47,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 8ea1c2e25b6..94166a8ab7e 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -98,16 +98,19 @@ async def decrement(hass: HomeAssistant, entity_id: str) -> None: ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 0, "max": 10, "initial": 11}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 153d8ed848d..c53e105bd09 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -70,17 +70,18 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"bad_initial": {"options": [1, 2], "initial": 3}}, - ] + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_select_option(hass: HomeAssistant) -> None: diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index fef2ff745cd..6fd6314c6bb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -131,16 +131,11 @@ def schedule_setup( return _schedule_setup -async def test_invalid_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, {"name with space": None}]) +async def test_invalid_config(hass: HomeAssistant, invalid_config) -> None: """Test invalid configs.""" - invalid_configs = [ - None, - {}, - {"name with space": None}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) @pytest.mark.parametrize( diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 6e68b354087..d2db9b094f5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -92,12 +92,11 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, 1, {"name with space": None}]) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: From 39d323186fedb7617502d0ab45e27a5b602770d9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 18 Jul 2025 10:53:47 +0300 Subject: [PATCH 0342/1118] Disable "last seen" Z-Wave entity by default (#148987) --- homeassistant/components/zwave_js/sensor.py | 2 +- tests/components/zwave_js/test_init.py | 8 ++++---- tests/components/zwave_js/test_sensor.py | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index df0a701bf15..2efb8c8e67c 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -558,7 +558,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, + entity_registry_enabled_default=False, ), ] diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 324a0f14941..930f27e73f0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -514,8 +514,8 @@ async def test_on_node_added_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_ready( @@ -631,8 +631,8 @@ async def test_existing_node_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_not_replaced_when_not_ready( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 42e2108be89..c7b41449d43 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -869,7 +869,7 @@ async def test_statistics_sensors_migration( ) -async def test_statistics_sensors_no_last_seen( +async def test_statistics_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, @@ -877,7 +877,7 @@ async def test_statistics_sensors_no_last_seen( integration, caplog: pytest.LogCaptureFixture, ) -> None: - """Test all statistics sensors but last seen which is enabled by default.""" + """Test statistics sensors.""" for prefix, suffixes in ( (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), @@ -1029,7 +1029,16 @@ async def test_last_seen_statistics_sensors( entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" entry = entity_registry.async_get(entity_id) assert entry - assert not entry.disabled + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state From 43a30fad96c89e694ce09b59c902caa5f4ebfff6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:19:33 +0200 Subject: [PATCH 0343/1118] Home Assistant Cloud: fix capitalization (#148992) --- homeassistant/components/cloud/http_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 998f3fcd5bc..49e4af9e3e5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -71,7 +71,7 @@ _CLOUD_ERRORS: dict[ ] = { TimeoutError: ( HTTPStatus.BAD_GATEWAY, - "Unable to reach the Home Assistant cloud.", + "Unable to reach the Home Assistant Cloud.", ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, From a96e31f1d8c63c96173ed7fffe40baa69fc0c651 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jul 2025 00:48:09 -1000 Subject: [PATCH 0344/1118] Bump PySwitchbot to 0.68.2 (#148996) --- 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 5ef7eec9976..22168c21f97 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.1"] + "requirements": ["PySwitchbot==0.68.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85da7a1f7b1..8a44f24c055 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.68.1 +PySwitchbot==0.68.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5377eb55c3a..16c620ff6db 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.68.1 +PySwitchbot==0.68.2 # homeassistant.components.syncthru PySyncThru==0.8.0 From 75c803e3767b61c50ce324be8e89cdf724b74825 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:48:39 +0200 Subject: [PATCH 0345/1118] Update pysmarlaapi to 0.9.1 (#149001) --- homeassistant/components/smarla/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 8f7786bdf72..e2e9e08dcab 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.9.0"] + "requirements": ["pysmarlaapi==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a44f24c055..2893a2960cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16c620ff6db..291c5e46a67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1949,7 +1949,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 From ec544b0430f97f10af6c7c68c06e4a865ce528bb Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:49:50 +0200 Subject: [PATCH 0346/1118] Mark entities as unavailable when they don't have a value in Husqvarna Automower (#148563) --- homeassistant/components/husqvarna_automower/sensor.py | 5 +++++ tests/components/husqvarna_automower/test_sensor.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 72f65320efd..0ff72271cb9 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -541,6 +541,11 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): """Return the state attributes.""" return self.entity_description.extra_state_attributes_fn(self.mower_attributes) + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return super().available and self.native_value is not None + class WorkAreaSensorEntity(WorkAreaAvailableEntity, SensorEntity): """Defining the Work area sensors with WorkAreaSensorEntityDescription.""" diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index b1029f5919b..d756b1b2ffa 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -39,7 +39,7 @@ async def test_sensor_unknown_states( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_mode") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_cutting_blade_usage_time_sensor( @@ -78,7 +78,7 @@ async def test_next_start_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_next_start") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_work_area_sensor( From 17034f4d6a60afb3063df889cc7fc9e63db2ce9e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 18 Jul 2025 13:15:55 +0200 Subject: [PATCH 0347/1118] Update frontend to 20250702.3 (#148994) --- 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 a7582ebc5e2..791acf8a39c 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==20250702.2"] + "requirements": ["home-assistant-frontend==20250702.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d26705842e2..f5f72d1c4c3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.107.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2893a2960cb..7da952c2f01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 291c5e46a67..c5ecaff0718 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 277241c4d3fc2bacd69343c92f5bda13ec8e6d5f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 18 Jul 2025 13:49:12 +0200 Subject: [PATCH 0348/1118] Adjust ManualTriggerSensorEntity to handle timestamp device classes (#145909) --- .../components/command_line/sensor.py | 13 +------ homeassistant/components/rest/sensor.py | 16 +------- homeassistant/components/scrape/sensor.py | 15 +------ homeassistant/components/snmp/sensor.py | 2 +- homeassistant/components/sql/sensor.py | 3 +- .../helpers/trigger_template_entity.py | 19 +++++++++ tests/helpers/test_trigger_template_entity.py | 39 +++++++++++++++++++ 7 files changed, 65 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index dfc31b4581b..234241fdeab 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -10,8 +10,6 @@ from typing import Any from jsonpath import jsonpath -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -188,16 +186,7 @@ class CommandSensor(ManualTriggerSensorEntity): self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - elif value is not None: - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9df10197a1a..3db44b0e5d2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, ) -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -181,18 +179,6 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): self.entity_id, variables, None ) - if value is None or self.device_class not in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ): - self._attr_native_value = value - self._process_manual_data(variables) - self.async_write_ha_state() - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 80d53a2c8b1..3e7f416166b 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -7,8 +7,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE_CLASS, @@ -218,17 +217,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - self._process_manual_data(variables) - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) @property diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 3574affaccd..46e0dc83050 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -217,7 +217,7 @@ class SnmpSensor(ManualTriggerSensorEntity): self.entity_id, variables, STATE_UNKNOWN ) - self._attr_native_value = value + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index b86a33db7ab..8c0ba81d6d2 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -401,9 +401,10 @@ class SQLSensor(ManualTriggerSensorEntity): if data is not None and self._template is not None: variables = self._template_variables_with_value(data) if self._render_availability_template(variables): - self._attr_native_value = self._template.async_render_as_value_template( + _value = self._template.async_render_as_value_template( self.entity_id, variables, None ) + self._set_native_value_with_possible_timestamp(_value) self._process_manual_data(variables) else: self._attr_native_value = data diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index bf7598eb024..d8ebab8b83e 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, @@ -389,3 +391,20 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): ManualTriggerEntity.__init__(self, hass, config) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = config.get(CONF_STATE_CLASS) + + @callback + def _set_native_value_with_possible_timestamp(self, value: Any) -> None: + """Set native value with possible timestamp. + + If self.device_class is `date` or `timestamp`, + it will try to parse the value to a date/datetime object. + """ + if self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._attr_native_value = value + elif value is not None: + self._attr_native_value = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 8389218054d..fcfdd249d75 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -4,7 +4,10 @@ from typing import Any import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_STATE, @@ -20,6 +23,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ManualTriggerSensorEntity, ValueTemplate, ) @@ -288,3 +292,38 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entity.some_other_key == {"test_key": "test_data"} + + +async def test_manual_trigger_sensor_entity_with_date( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), + CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + } + + class TestEntity(ManualTriggerSensorEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return "2025-01-01T00:00:00+00:00" + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value("2025-01-01T00:00:00+00:00") + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._set_native_value_with_possible_timestamp(entity.state) + await hass.async_block_till_done() + + assert entity.native_value == async_parse_date_datetime( + "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class + ) + assert entity.state == "2025-01-01T00:00:00+00:00" + assert entity.device_class == SensorDeviceClass.TIMESTAMP From 1743766d170c09d1490652413edec89104df7808 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 18 Jul 2025 13:53:30 +0200 Subject: [PATCH 0349/1118] Add last_reported to state reported event data (#148932) --- homeassistant/components/derivative/sensor.py | 37 +++++++----- .../components/integration/sensor.py | 38 ++++++++---- homeassistant/components/statistics/sensor.py | 23 ++++--- homeassistant/core.py | 60 +++++++++++++++---- 4 files changed, 109 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab4feabc4ee..da35975c193 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -320,7 +320,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # changed state, then we know it will still be zero. return schedule_max_sub_interval_exceeded(new_state) - calc_derivative(new_state, new_state.state, event.data["old_last_reported"]) + calc_derivative( + new_state, + new_state.state, + event.data["last_reported"], + event.data["old_last_reported"], + ) @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: @@ -334,19 +339,27 @@ class DerivativeSensor(RestoreSensor, SensorEntity): schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] if old_state is not None: - calc_derivative(new_state, old_state.state, old_state.last_reported) + calc_derivative( + new_state, + old_state.state, + new_state.last_updated, + old_state.last_reported, + ) else: # On first state change from none, update availability self.async_write_ha_state() def calc_derivative( - new_state: State, old_value: str, old_last_reported: datetime + new_state: State, + old_value: str, + new_timestamp: datetime, + old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" if not _is_decimal_state(old_value): if self._last_valid_state_time: old_value = self._last_valid_state_time[0] - old_last_reported = self._last_valid_state_time[1] + old_timestamp = self._last_valid_state_time[1] else: # Sensor becomes valid for the first time, just keep the restored value self.async_write_ha_state() @@ -358,12 +371,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "" if unit is None else unit ) - self._prune_state_list(new_state.last_reported) + self._prune_state_list(new_timestamp) try: - elapsed_time = ( - new_state.last_reported - old_last_reported - ).total_seconds() + elapsed_time = (new_timestamp - old_timestamp).total_seconds() delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value @@ -392,12 +403,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): return # add latest derivative to the window list - self._state_list.append( - (old_last_reported, new_state.last_reported, new_derivative) - ) + self._state_list.append((old_timestamp, new_timestamp, new_derivative)) self._last_valid_state_time = ( new_state.state, - new_state.last_reported, + new_timestamp, ) # If outside of time window just report derivative (is the same as modeling it in the window), @@ -405,9 +414,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = self._calc_derivative_from_state_list( - new_state.last_reported - ) + derivative = self._calc_derivative_from_state_list(new_timestamp) self._write_native_value(derivative) source_state = self.hass.states.get(self._sensor_source_id) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 25181ac6149..49a032899be 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -463,7 +463,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state update when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -472,13 +472,17 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) @callback def _integrate_on_state_update_with_max_sub_interval( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -489,7 +493,9 @@ class IntegrationSensor(RestoreSensor): """ self._cancel_max_sub_interval_exceeded_callback() try: - self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._integrate_on_state_change( + old_timestamp, new_timestamp, old_state, new_state + ) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: @@ -503,7 +509,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state change.""" return self._integrate_on_state_change( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -512,12 +518,16 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report.""" return self._integrate_on_state_change( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) def _integrate_on_state_change( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -531,16 +541,17 @@ class IntegrationSensor(RestoreSensor): if old_state: # state has changed, we recover old_state from the event + new_timestamp = new_state.last_updated old_state_state = old_state.state - old_last_reported = old_state.last_reported + old_timestamp = old_state.last_reported else: - # event state reported without any state change + # first state or event state reported without any state change old_state_state = new_state.state self._attr_available = True self._derive_and_set_attributes_from_state(new_state) - if old_last_reported is None and old_state is None: + if old_timestamp is None and old_state is None: self.async_write_ha_state() return @@ -551,11 +562,12 @@ class IntegrationSensor(RestoreSensor): return if TYPE_CHECKING: - assert old_last_reported is not None + assert new_timestamp is not None + assert old_timestamp is not None elapsed_seconds = Decimal( - (new_state.last_reported - old_last_reported).total_seconds() + (new_timestamp - old_timestamp).total_seconds() if self._last_integration_trigger == _IntegrationTrigger.StateEvent - else (new_state.last_reported - self._last_integration_time).total_seconds() + else (new_timestamp - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 8129a000b91..14471ab16ee 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -727,12 +727,11 @@ class StatisticsSensor(SensorEntity): def _async_handle_new_state( self, - reported_state: State | None, + reported_state: State, + timestamp: float, ) -> None: """Handle the sensor state changes.""" - if (new_state := reported_state) is None: - return - self._add_state_to_queue(new_state) + self._add_state_to_queue(reported_state, timestamp) self._async_purge_update_and_schedule() if self._preview_callback: @@ -747,14 +746,18 @@ class StatisticsSensor(SensorEntity): self, event: Event[EventStateChangedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + if (new_state := event.data["new_state"]) is None: + return + self._async_handle_new_state(new_state, new_state.last_updated_timestamp) @callback def _async_stats_sensor_state_report_listener( self, event: Event[EventStateReportedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + self._async_handle_new_state( + event.data["new_state"], event.data["last_reported"].timestamp() + ) async def _async_stats_sensor_startup(self) -> None: """Add listener and get recorded state. @@ -785,7 +788,9 @@ class StatisticsSensor(SensorEntity): """Register callbacks.""" await self._async_stats_sensor_startup() - def _add_state_to_queue(self, new_state: State) -> None: + def _add_state_to_queue( + self, new_state: State, last_reported_timestamp: float + ) -> None: """Add the state to the queue.""" # Attention: it is not safe to store the new_state object, @@ -805,7 +810,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported_timestamp) + self.ages.append(last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -1062,7 +1067,7 @@ class StatisticsSensor(SensorEntity): self._fetch_states_from_database ): for state in reversed(states): - self._add_state_to_queue(state) + self._add_state_to_queue(state, state.last_reported_timestamp) self._calculate_state_attributes(state) self._async_purge_update_and_schedule() diff --git a/homeassistant/core.py b/homeassistant/core.py index 8ffabf56171..299a7d32306 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -157,7 +157,6 @@ class EventStateEventData(TypedDict): """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" entity_id: str - new_state: State | None class EventStateChangedData(EventStateEventData): @@ -166,6 +165,7 @@ class EventStateChangedData(EventStateEventData): A state changed event is fired when on state write the state is changed. """ + new_state: State | None old_state: State | None @@ -175,6 +175,8 @@ class EventStateReportedData(EventStateEventData): A state reported event is fired when on state write the state is unchanged. """ + last_reported: datetime.datetime + new_state: State old_last_reported: datetime.datetime @@ -1749,18 +1751,38 @@ class CompressedState(TypedDict): class State: - """Object to represent a state within the state machine. + """Object to represent a state within the state machine.""" - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed. - last_reported: last time the state was reported. - last_updated: last time the state or attributes were changed. - context: Context in which it was created - domain: Domain of this state. - object_id: Object id of this state. + entity_id: str + """The entity that is represented by the state.""" + domain: str + """Domain of the entity that is represented by the state.""" + object_id: str + """object_id: Object id of this state.""" + state: str + """The state of the entity.""" + attributes: ReadOnlyDict[str, Any] + """Extra information on entity and state""" + last_changed: datetime.datetime + """Last time the state was changed.""" + last_reported: datetime.datetime + """Last time the state was reported. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported attribute of the old + state will not be modified and can safely be used. The last_reported attribute + of the new state may be modified and the last_updated attribute should be used + instead. + + When handling a state report event, the last_reported attribute may be + modified and last_reported from the event data should be used instead. """ + last_updated: datetime.datetime + """Last time the state or attributes were changed.""" + context: Context + """Context in which the state was created.""" __slots__ = ( "_cache", @@ -1841,7 +1863,20 @@ class State: @under_cached_property def last_reported_timestamp(self) -> float: - """Timestamp of last report.""" + """Timestamp of last report. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported_timestamp attribute + of the old state will not be modified and can safely be used. The + last_reported_timestamp attribute of the new state may be modified and the + last_updated_timestamp attribute should be used instead. + + When handling a state report event, the last_reported_timestamp attribute may + be modified and last_reported from the event data should be used instead. + """ + return self.last_reported.timestamp() @under_cached_property @@ -2340,6 +2375,7 @@ class StateMachine: EVENT_STATE_REPORTED, { "entity_id": entity_id, + "last_reported": now, "old_last_reported": old_last_reported, "new_state": old_state, }, From 29d0d6cd43a01ab09ccdbdc6b59e8c3aebcd6d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 18 Jul 2025 14:32:16 +0100 Subject: [PATCH 0350/1118] Add top-level target support to trigger schema (#148894) --- script/hassfest/triggers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index ff6654f2789..8efaab47050 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -38,6 +38,9 @@ FIELD_SCHEMA = vol.Schema( TRIGGER_SCHEMA = vol.Any( vol.Schema( { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ), From 3b89b2cbbe062432a41694546ea94398ba8c1a87 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Jul 2025 16:35:38 +0300 Subject: [PATCH 0351/1118] Bump aioamazondevices to 3.5.0 (#149011) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 25ad75d0d00..9a98be052be 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.2.10"] + "requirements": ["aioamazondevices==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7da952c2f01..ca38d1b2743 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.15 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5ecaff0718..e4590e45908 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.15 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 109663f1777d6dada2d98264fd1a874b755edf27 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 18 Jul 2025 15:36:17 +0200 Subject: [PATCH 0352/1118] Bump `imgw_pib` to version 1.4.2 (#149009) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index e2032b6d51a..7b7c66a953d 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.4.1"] + "requirements": ["imgw_pib==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca38d1b2743..6005c7a0dff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.1 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4590e45908..9487ea55ce7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.1 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 From 353b573707814156ed28415755e6b266d8d71f64 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:43:43 +0200 Subject: [PATCH 0353/1118] Update bluecurrent-api to 1.2.4 (#149005) --- homeassistant/components/blue_current/manifest.json | 2 +- pyproject.toml | 4 ---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index e813b08131c..84604c62951 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], - "requirements": ["bluecurrent-api==1.2.3"] + "requirements": ["bluecurrent-api==1.2.4"] } diff --git a/pyproject.toml b/pyproject.toml index 3b0994ff2cf..6c732066e41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -589,10 +589,6 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 - "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", diff --git a/requirements_all.txt b/requirements_all.txt index 6005c7a0dff..48a5e2a17c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9487ea55ce7..202d6826562 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -565,7 +565,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 From 4c99fe9ae5376dc177a47e6e899a4371e99e2485 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 18 Jul 2025 18:57:03 +0200 Subject: [PATCH 0354/1118] Ignore MQTT sensor unit of measurement if it is an empty string (#149006) --- homeassistant/components/mqtt/sensor.py | 6 ++++ tests/components/mqtt/test_sensor.py | 39 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 783a0b30b14..83679894d71 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -98,6 +98,12 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class `{state_class}`" ) + unit_of_measurement: str | None + if ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is not None and not unit_of_measurement.strip(): + config.pop(CONF_UNIT_OF_MEASUREMENT) + # Only allow `options` to be set for `enum` sensors # to limit the possible sensor values if (options := config.get(CONF_OPTIONS)) is not None: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 997c014cd13..16f0c9f22bc 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -924,6 +924,30 @@ async def test_invalid_unit_of_measurement( "device_class": None, "unit_of_measurement": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": "", + }, + { + "name": "Test 5", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": " ", + }, + { + "name": "Test 6", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": "", + }, + { + "name": "Test 7", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": " ", + }, ] } } @@ -936,10 +960,25 @@ async def test_valid_device_class_and_uom( await mqtt_mock_entry() state = hass.states.get("sensor.test_1") + assert state is not None assert state.attributes["device_class"] == "temperature" state = hass.states.get("sensor.test_2") + assert state is not None assert "device_class" not in state.attributes state = hass.states.get("sensor.test_3") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_4") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_5") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_6") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_7") + assert state is not None assert "device_class" not in state.attributes From c6d0aad3d33049cd02dea204f6c3b021f52a42c5 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:15:19 +0200 Subject: [PATCH 0355/1118] Handle connection issues after websocket reconnected in homematicip_cloud (#147731) --- .../components/homematicip_cloud/hap.py | 63 ++++++++++++------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homematicip_cloud/test_device.py | 11 +++- .../components/homematicip_cloud/test_hap.py | 61 ++++++++++++++++-- 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index c42ebff200d..d66594da390 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -113,9 +113,7 @@ class HomematicipHAP: self._ws_close_requested = False self._ws_connection_closed = asyncio.Event() - self._retry_task: asyncio.Task | None = None - self._tries = 0 - self._accesspoint_connected = True + self._get_state_task: asyncio.Task | None = None self.hmip_device_by_entity_id: dict[str, Any] = {} self.reset_connection_listener: Callable | None = None @@ -161,17 +159,8 @@ class HomematicipHAP: """ if not self.home.connected: _LOGGER.error("HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False + self._ws_connection_closed.set() self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Now the HOME_CHANGED event has fired indicating the access - # point has reconnected to the cloud again. - # Explicitly getting an update as entity states might have - # changed during access point disconnect.""" - - job = self.hass.async_create_task(self.get_state()) - job.add_done_callback(self.get_state_finished) - self._accesspoint_connected = True @callback def async_create_entity(self, *args, **kwargs) -> None: @@ -185,20 +174,43 @@ class HomematicipHAP: await asyncio.sleep(30) await self.hass.config_entries.async_reload(self.config_entry.entry_id) + async def _try_get_state(self) -> None: + """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + + # Wait until WebSocket connection is established. + while not self.home.websocket_is_connected(): + await asyncio.sleep(2) + + delay = 8 + max_delay = 1500 + while True: + try: + await self.get_state() + break + except HmipConnectionError as err: + _LOGGER.warning( + "Get_state failed, retrying in %s seconds: %s", delay, err + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: - """Execute when get_state coroutine has finished.""" + """Execute when try_get_state coroutine has finished.""" try: future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error("Updating state after HMIP access point reconnect failed") - self.hass.async_create_task(self.home.disable_events()) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error updating state after HMIP access point reconnect: %s", err + ) + else: + _LOGGER.info( + "Updating state after HMIP access point reconnect finished successfully", + ) def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" @@ -222,8 +234,8 @@ class HomematicipHAP: async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() + if self._get_state_task is not None: + self._get_state_task.cancel() await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( @@ -247,7 +259,9 @@ class HomematicipHAP: """Handle websocket connected.""" _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): - await self.get_state() + self._get_state_task = self.hass.async_create_task(self._try_get_state()) + self._get_state_task.add_done_callback(self.get_state_finished) + self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: @@ -256,11 +270,12 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection.""" + """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", reason, ) + self._ws_connection_closed.set() async def get_hap( diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d5af2859873..036ffa286a3 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.6"] + "requirements": ["homematicip==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index c7d125c0700..4d9efd17d23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a388e6dbf87..dd15dfca16a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index abd0e18b368..48ad8806d0c 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -195,9 +195,14 @@ async def test_hap_reconnected( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNAVAILABLE - mock_hap._accesspoint_connected = False - await async_manipulate_test_data(hass, mock_hap.home, "connected", True) - await hass.async_block_till_done() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await async_manipulate_test_data(hass, mock_hap.home, "connected", True) + await mock_hap.ws_connected_handler() + await hass.async_block_till_done() + ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ae094f7dded..69078beafaf 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -242,7 +242,14 @@ async def test_get_state_after_disconnect( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "get_state") as mock_get_state: + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + hap.home = simple_mock_home + hap.home.websocket_is_connected = Mock(side_effect=[False, True]) + + with ( + patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, + patch.object(hap, "get_state") as mock_get_state, + ): assert not hap._ws_connection_closed.is_set() await hap.ws_connected_handler() @@ -250,8 +257,54 @@ async def test_get_state_after_disconnect( await hap.ws_disconnected_handler() assert hap._ws_connection_closed.is_set() - await hap.ws_connected_handler() - mock_get_state.assert_called_once() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await hap.ws_connected_handler() + mock_get_state.assert_called_once() + + assert not hap._ws_connection_closed.is_set() + hap.home.websocket_is_connected.assert_called() + mock_sleep.assert_awaited_with(2) + + +async def test_try_get_state_exponential_backoff() -> None: + """Test _try_get_state waits for websocket connection.""" + + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + hap.home.websocket_is_connected = Mock(return_value=True) + + hap.get_state = AsyncMock( + side_effect=[HmipConnectionError, HmipConnectionError, True] + ) + + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + await hap._try_get_state() + + assert mock_sleep.mock_calls[0].args[0] == 8 + assert mock_sleep.mock_calls[1].args[0] == 16 + assert hap.get_state.call_count == 3 + + +async def test_try_get_state_handle_exception() -> None: + """Test _try_get_state handles exceptions.""" + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + + expected_exception = Exception("Connection error") + future = AsyncMock() + future.result = Mock(side_effect=expected_exception) + + with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger: + hap.get_state_finished(future) + + mock_logger.error.assert_called_once_with( + "Error updating state after HMIP access point reconnect: %s", expected_exception + ) async def test_async_connect( From caf049200933178fa8c6c134ea976026700c63a6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 14 Jul 2025 22:40:46 +0200 Subject: [PATCH 0356/1118] Fix Shelly `n_current` sensor removal condition (#148740) --- homeassistant/components/shelly/sensor.py | 4 +- tests/components/shelly/fixtures/pro_3em.json | 2 +- .../shelly/snapshots/test_devices.ambr | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 3a6f5f221c5..cefcbb86a98 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -868,8 +868,8 @@ RPC_SENSORS: Final = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: (status and status["n_current"]) is not None, - removal_condition=lambda _config, status, _key: "n_current" not in status, + removal_condition=lambda _config, status, key: status[key].get("n_current") + is None, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json index 93351e9bc65..4895766cc49 100644 --- a/tests/components/shelly/fixtures/pro_3em.json +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -151,7 +151,7 @@ "c_pf": 0.72, "c_voltage": 230.2, "id": 0, - "n_current": null, + "n_current": 3.124, "total_act_power": 2413.825, "total_aprt_power": 2525.779, "total_current": 11.116, diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 0b8ec71771b..9dcda321057 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -4303,6 +4303,62 @@ 'state': '230.2', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_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.test_name_phase_n_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase N current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-n_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase N current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_n_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.124', + }) +# --- # name: test_shelly_pro_3em[sensor.test_name_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 56e0aa103dbb3ee41f5b8146dde0be7512f459ca Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Jul 2025 21:18:12 +0200 Subject: [PATCH 0357/1118] Bump pySmartThings to 3.2.8 (#148761) --- 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 2c4974a6567..35354570f23 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.2.7"] + "requirements": ["pysmartthings==3.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d9efd17d23..e69675779db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd15dfca16a..533573e2a36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 From 69fdc1d26930484895a79d3302a07d894ff7201b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 15 Jul 2025 17:21:00 +1000 Subject: [PATCH 0358/1118] Bump Tesla Fleet API to 1.2.2 (#148776) --- 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 4c92e0bd222..cf86fbeb4f9 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.2.0"] + "requirements": ["tesla-fleet-api==1.2.2"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index f58783e04a4..d12cf278d59 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.2.0", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c0cbc2ea431..26f26990d58 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.2.0"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e69675779db..61e344b9f3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2904,7 +2904,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 533573e2a36..554905e48fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2390,7 +2390,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 9e4b8df344c2f854904689c8812056829c4a2e96 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 17:38:19 +0200 Subject: [PATCH 0359/1118] Use ffmpeg for generic cameras in go2rtc (#148818) --- homeassistant/components/go2rtc/__init__.py | 5 ++++ tests/components/go2rtc/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..8d3e988dd14 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..dcbcb629d11 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -670,3 +670,32 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + camera.set_stream_source("https://my_stream_url.m3u8") + + with patch.object(camera.platform, "platform_name", "generic"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) From 8e0a89dc2f295cb24ff80a5440817695405a66b2 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:09:24 -0400 Subject: [PATCH 0360/1118] Add guard to prevent exception in Sonos Favorites (#148854) --- homeassistant/components/sonos/favorites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index f8b3dbbe492..f3471d01da1 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -72,7 +72,7 @@ class SonosFavorites(SonosHouseholdCoordinator): """Process the event payload in an async lock and update entities.""" event_id = event.variables["favorites_update_id"] container_ids = event.variables["container_update_i_ds"] - if not (match := re.search(r"FV:2,(\d+)", container_ids)): + if not container_ids or not (match := re.search(r"FV:2,(\d+)", container_ids)): return container_id = int(match.groups()[0]) From 1644484c92abb269b469d3b09f31e399065aa24c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 16:16:36 +1000 Subject: [PATCH 0361/1118] Fix button platform parent class in Teslemetry (#148863) --- homeassistant/components/teslemetry/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index cf1d6157ec1..12772b894b6 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehiclePollingEntity +from .entity import TeslemetryVehicleStreamEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -74,7 +74,7 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" api: Vehicle From 11a2c73e8ace4c1102a207f40a10e166e8e8dd28 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:18:14 +0200 Subject: [PATCH 0362/1118] Bump pyenphase to 2.2.2 (#148870) --- 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 278045001fc..320179bf2df 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.1"], + "requirements": ["pyenphase==2.2.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 61e344b9f3e..6451b711663 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 554905e48fd..70748d6c5fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.everlights pyeverlights==0.1.0 From 7a3eb53453e1f5f7af9a76873a03c5d5e4353aca Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Jul 2025 16:11:15 +0200 Subject: [PATCH 0363/1118] Bump gios to version 6.1.1 (#148414) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index ba87890de03..1782320a357 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.0"] + "requirements": ["gios==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6451b711663..ca5075fd995 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70748d6c5fe..2e0f0a0d96c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.glances glances-api==0.8.0 From b6edcc942283b15a42b4a78a6e223ea4f6c4420a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 15:49:02 +0200 Subject: [PATCH 0364/1118] Bump `gios` to version 6.1.2 (#148884) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 1782320a357..8c6765ece89 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.1"] + "requirements": ["gios==6.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca5075fd995..967535ca0b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e0f0a0d96c..337ed2e849d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.glances glances-api==0.8.0 From 5656b4c20d4fecc63c7a6c42a8071fab56a06760 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Thu, 17 Jul 2025 21:19:47 +0200 Subject: [PATCH 0365/1118] Bump async-upnp-client to 0.45.0 (#148961) --- 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 119d1d31d52..eac8ddcf713 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.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.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 0289d5100d6..4a73bf779e0 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.44.0"], + "requirements": ["async-upnp-client==0.45.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 a2ab8e6e466..1b927757a39 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.44.0" + "async-upnp-client==0.45.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 93943b0a9ea..2471e45b4e0 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.44.0"] + "requirements": ["async-upnp-client==0.45.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 62ee4ede7d9..825c5774c1d 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.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.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 07970cb25ca..d65ebb3a25a 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.44.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01bf8e24885..98124be5c64 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.44.0 +async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 attrs==25.3.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 967535ca0b7..4b3048742a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,7 +527,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 337ed2e849d..6e0659ce614 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -491,7 +491,7 @@ arcam-fmj==1.8.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 From 8fdc50a29f6ac601b3d57c68a0a9e63840280898 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:14:19 +0200 Subject: [PATCH 0366/1118] Pass Syncthru entry to coordinator (#148974) --- homeassistant/components/syncthru/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 0b96b354436..27239a5a520 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -28,6 +28,7 @@ class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_interval=timedelta(seconds=30), ) self.syncthru = SyncThru( From 68889e1790b09fb180f2b9c825ccae9b68efaa92 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 18 Jul 2025 13:15:55 +0200 Subject: [PATCH 0367/1118] Update frontend to 20250702.3 (#148994) --- 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 a7582ebc5e2..791acf8a39c 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==20250702.2"] + "requirements": ["home-assistant-frontend==20250702.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98124be5c64..e783e8e0743 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4b3048742a9..c65bc509fa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e0659ce614..560071f9644 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From d57c5ffa8fb4e44a377d55cdb5660ddb4cfe03fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jul 2025 00:48:09 -1000 Subject: [PATCH 0368/1118] Bump PySwitchbot to 0.68.2 (#148996) --- 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 5ef7eec9976..22168c21f97 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.1"] + "requirements": ["PySwitchbot==0.68.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c65bc509fa0..af8161e4636 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.68.1 +PySwitchbot==0.68.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 560071f9644..e0b95a9c4ce 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.68.1 +PySwitchbot==0.68.2 # homeassistant.components.syncthru PySyncThru==0.8.0 From c6bb26be89106bd1990cb9a0411c60b841e90ce6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 18 Jul 2025 18:57:03 +0200 Subject: [PATCH 0369/1118] Ignore MQTT sensor unit of measurement if it is an empty string (#149006) --- homeassistant/components/mqtt/sensor.py | 6 ++++ tests/components/mqtt/test_sensor.py | 39 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 783a0b30b14..83679894d71 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -98,6 +98,12 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class `{state_class}`" ) + unit_of_measurement: str | None + if ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is not None and not unit_of_measurement.strip(): + config.pop(CONF_UNIT_OF_MEASUREMENT) + # Only allow `options` to be set for `enum` sensors # to limit the possible sensor values if (options := config.get(CONF_OPTIONS)) is not None: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 997c014cd13..16f0c9f22bc 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -924,6 +924,30 @@ async def test_invalid_unit_of_measurement( "device_class": None, "unit_of_measurement": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": "", + }, + { + "name": "Test 5", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": " ", + }, + { + "name": "Test 6", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": "", + }, + { + "name": "Test 7", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": " ", + }, ] } } @@ -936,10 +960,25 @@ async def test_valid_device_class_and_uom( await mqtt_mock_entry() state = hass.states.get("sensor.test_1") + assert state is not None assert state.attributes["device_class"] == "temperature" state = hass.states.get("sensor.test_2") + assert state is not None assert "device_class" not in state.attributes state = hass.states.get("sensor.test_3") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_4") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_5") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_6") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_7") + assert state is not None assert "device_class" not in state.attributes From 190c98f5a8252cf9b6164e15f99762b1fbeab329 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Jul 2025 16:35:38 +0300 Subject: [PATCH 0370/1118] Bump aioamazondevices to 3.5.0 (#149011) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 0cb99ba090e..6904f8000ce 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.2.10"] + "requirements": ["aioamazondevices==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index af8161e4636..c9f708ad9ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0b95a9c4ce..2e0ad947c38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0675e34c6254fdb2d3e528b1707c432e8da66173 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Jul 2025 17:05:52 +0000 Subject: [PATCH 0371/1118] Bump version to 2025.7.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9dd3c3480f9..62e6d49befd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 47c38246f29..c010ecb7254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.2" +version = "2025.7.3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 916b4368dd2eaa7d407565db31a89147617003cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jul 2025 07:30:34 -1000 Subject: [PATCH 0372/1118] Bump aioesphomeapi to 36.0.1 (#148991) --- .../components/esphome/entry_data.py | 18 +------- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../esphome/test_alarm_control_panel.py | 3 -- .../esphome/test_assist_satellite.py | 4 -- .../components/esphome/test_binary_sensor.py | 8 ---- tests/components/esphome/test_button.py | 1 - tests/components/esphome/test_camera.py | 6 --- tests/components/esphome/test_climate.py | 7 --- tests/components/esphome/test_cover.py | 2 - tests/components/esphome/test_date.py | 2 - tests/components/esphome/test_datetime.py | 2 - tests/components/esphome/test_entity.py | 46 ++----------------- tests/components/esphome/test_entry_data.py | 44 ------------------ tests/components/esphome/test_event.py | 1 - tests/components/esphome/test_fan.py | 3 -- tests/components/esphome/test_light.py | 20 -------- tests/components/esphome/test_lock.py | 3 -- tests/components/esphome/test_media_player.py | 4 -- tests/components/esphome/test_number.py | 4 -- tests/components/esphome/test_repairs.py | 1 - tests/components/esphome/test_select.py | 1 - tests/components/esphome/test_sensor.py | 14 ------ tests/components/esphome/test_switch.py | 3 -- tests/components/esphome/test_text.py | 3 -- tests/components/esphome/test_time.py | 2 - tests/components/esphome/test_update.py | 3 -- tests/components/esphome/test_valve.py | 2 - 29 files changed, 8 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index dddbb598a57..eddd4d523c9 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -295,23 +295,7 @@ class RuntimeEntryData: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) - ent_reg = er.async_get(hass) - registry_get_entity = ent_reg.async_get_entity_id - for info in infos: - platform = INFO_TYPE_TO_PLATFORM[type(info)] - needed_platforms.add(platform) - # If the unique id is in the old format, migrate it - # except if they downgraded and upgraded, there might be a duplicate - # so we want to keep the one that was already there. - if ( - (old_unique_id := info.unique_id) - and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) - and (new_unique_id := build_device_unique_id(mac, info)) - != old_unique_id - and not registry_get_entity(platform, DOMAIN, new_unique_id) - ): - ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) - + needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos) await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Make a dict of the EntityInfo by type and send diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c88fa7246fe..903aaea9980 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==35.0.0", + "aioesphomeapi==36.0.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 48a5e2a17c1..03019fcc39e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==35.0.0 +aioesphomeapi==36.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 202d6826562..0042ef7aa34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==35.0.0 +aioesphomeapi==36.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index e06b88432a9..ff16731b44e 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -40,7 +40,6 @@ async def test_generic_alarm_control_panel_requires_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -173,7 +172,6 @@ async def test_generic_alarm_control_panel_no_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -219,7 +217,6 @@ async def test_generic_alarm_control_panel_missing_state( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index bfcc35b2e6a..2fdf53dc5ea 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -953,7 +953,6 @@ async def test_tts_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1020,7 +1019,6 @@ async def test_tts_minimal_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1156,7 +1154,6 @@ async def test_announce_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1437,7 +1434,6 @@ async def test_start_conversation_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index d6e94e61766..0e3bcc5a115 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -24,7 +24,6 @@ async def test_binary_sensor_generic_entity( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] esphome_state, hass_state = binary_state @@ -52,7 +51,6 @@ async def test_status_binary_sensor( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] @@ -80,7 +78,6 @@ async def test_binary_sensor_missing_state( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [BinarySensorState(key=1, state=True, missing_state=True)] @@ -107,7 +104,6 @@ async def test_binary_sensor_has_state_false( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -152,14 +148,12 @@ async def test_binary_sensors_same_key_different_device_id( object_id="sensor", key=1, name="Motion", - unique_id="motion_1", device_id=11111111, ), BinarySensorInfo( object_id="sensor", key=1, name="Motion", - unique_id="motion_2", device_id=22222222, ), ] @@ -235,14 +229,12 @@ async def test_binary_sensor_main_and_sub_device_same_key( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_1", device_id=0, # Main device ), BinarySensorInfo( object_id="sub_sensor", key=1, name="Sub Sensor", - unique_id="sub_1", device_id=11111111, ), ] diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index 3cedc3526d4..b85dd04e6b7 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -18,7 +18,6 @@ async def test_button_generic_entity( object_id="mybutton", key=1, name="my button", - unique_id="my_button", ) ] states = [] diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index e29eed16d9f..2f3966fe1f6 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -30,7 +30,6 @@ async def test_camera_single_image( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -75,7 +74,6 @@ async def test_camera_single_image_unavailable_before_requested( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -113,7 +111,6 @@ async def test_camera_single_image_unavailable_during_request( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -155,7 +152,6 @@ async def test_camera_stream( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -212,7 +208,6 @@ async def test_camera_stream_unavailable( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -249,7 +244,6 @@ async def test_camera_stream_with_disconnection( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 5c907eef3b1..c574764e3c9 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -58,7 +58,6 @@ async def test_climate_entity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_action=True, visual_min_temperature=10.0, @@ -110,7 +109,6 @@ async def test_climate_entity_with_step_and_two_point( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, visual_target_temperature_step=2, @@ -187,7 +185,6 @@ async def test_climate_entity_with_step_and_target_temp( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -345,7 +342,6 @@ async def test_climate_entity_with_humidity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -409,7 +405,6 @@ async def test_climate_entity_with_inf_value( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -465,7 +460,6 @@ async def test_climate_entity_attributes( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -520,7 +514,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=False, ) ] diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 93524905f6b..d7b92e490fe 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -41,7 +41,6 @@ async def test_cover_entity( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=True, supports_tilt=True, supports_stop=True, @@ -169,7 +168,6 @@ async def test_cover_entity_without_position( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=False, supports_tilt=False, supports_stop=False, diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 387838e0b23..9e555eb98c2 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -26,7 +26,6 @@ async def test_generic_date_entity( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, year=2024, month=12, day=31)] @@ -62,7 +61,6 @@ async def test_generic_date_missing_state( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 6fcfe7ed947..940fae5cfef 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -26,7 +26,6 @@ async def test_generic_datetime_entity( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, epoch_seconds=1713270896)] @@ -65,7 +64,6 @@ async def test_generic_datetime_missing_state( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index f364e1f528f..9b3c08bb77d 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -51,13 +51,11 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -100,7 +98,6 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -140,13 +137,11 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -214,7 +209,6 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] mock_device.client.list_entities_services = AsyncMock( @@ -267,7 +261,6 @@ async def test_entities_for_entire_platform_removed( object_id="mybinary_sensor_to_be_removed", key=1, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -325,7 +318,6 @@ async def test_entity_info_object_ids( object_id="object_id_is_used", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -350,13 +342,11 @@ async def test_deep_sleep_device( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), SensorInfo( object_id="my_sensor", key=3, name="my sensor", - unique_id="my_sensor", ), ] states = [ @@ -456,7 +446,6 @@ async def test_esphome_device_without_friendly_name( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -486,7 +475,6 @@ async def test_entity_without_name_device_with_friendly_name( object_id="mybinary_sensor", key=1, name="", - unique_id="my_binary_sensor", ), ] states = [ @@ -519,7 +507,6 @@ async def test_entity_id_preserved_on_upgrade( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -560,7 +547,6 @@ async def test_entity_id_preserved_on_upgrade_old_format_entity_id( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -601,7 +587,6 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -660,7 +645,6 @@ async def test_deep_sleep_added_after_setup( object_id="test", key=1, name="test", - unique_id="test", ), ], states=[ @@ -732,7 +716,6 @@ async def test_entity_assignment_to_sub_device( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -740,7 +723,6 @@ async def test_entity_assignment_to_sub_device( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -748,7 +730,6 @@ async def test_entity_assignment_to_sub_device( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), ] @@ -932,7 +913,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - entity belongs to main device ), ] @@ -964,7 +944,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=11111111, # Now on sub device 1 ), ] @@ -993,7 +972,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=22222222, # Now on sub device 2 ), ] @@ -1020,7 +998,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - back to main device ), ] @@ -1063,7 +1040,6 @@ async def test_entity_id_uses_sub_device_name( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -1071,7 +1047,6 @@ async def test_entity_id_uses_sub_device_name( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -1079,7 +1054,6 @@ async def test_entity_id_uses_sub_device_name( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), # Entity without name on sub device @@ -1087,7 +1061,6 @@ async def test_entity_id_uses_sub_device_name( object_id="sensor_no_name", key=4, name="", - unique_id="sensor_no_name", device_id=11111111, ), ] @@ -1147,7 +1120,6 @@ async def test_entity_id_with_empty_sub_device_name( object_id="sensor", key=1, name="Sensor", - unique_id="sensor", device_id=11111111, ), ] @@ -1187,8 +1159,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", key=1, - name="Temperature", - unique_id="unused", # This field is not used by the integration + name="Temperature", # This field is not used by the integration device_id=0, # Main device ), ] @@ -1250,8 +1221,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", # Same object_id key=1, # Same key - this is what identifies the entity - name="Temperature", - unique_id="unused", # This field is not used + name="Temperature", # This field is not used device_id=22222222, # Now on sub-device ), ] @@ -1312,7 +1282,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On sub-device ), ] @@ -1347,7 +1316,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=0, # Now on main device ), ] @@ -1407,7 +1375,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On kitchen_controller ), ] @@ -1442,7 +1409,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=33333333, # Now on bedroom_controller ), ] @@ -1501,7 +1467,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", key=1, name="Sensor", - unique_id="unused", device_id=11111111, ), ] @@ -1563,7 +1528,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", # Same object_id key=1, # Same key name="Sensor", - unique_id="unused", device_id=99999999, # New device_id after rename ), ] @@ -1636,8 +1600,7 @@ async def test_entity_with_unicode_name( BinarySensorInfo( object_id=sanitized_object_id, # ESPHome sends the sanitized version key=1, - name=unicode_name, # But also sends the original Unicode name - unique_id="unicode_sensor", + name=unicode_name, # But also sends the original Unicode name, ) ] states = [BinarySensorState(key=1, state=True)] @@ -1677,8 +1640,7 @@ async def test_entity_without_name_uses_device_name_only( BinarySensorInfo( object_id="some_sanitized_id", key=1, - name="", # Empty name - unique_id="no_name_sensor", + name="", # Empty name, ) ] states = [BinarySensorState(key=1, state=True)] diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 886e5317462..044c3c7a8f1 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -15,49 +15,6 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockGenericDeviceEntryType -async def test_migrate_entity_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_client: APIClient, - mock_generic_device_entry: MockGenericDeviceEntryType, -) -> None: - """Test a generic sensor entity unique id migration.""" - entity_registry.async_get_or_create( - "sensor", - "esphome", - "my_sensor", - suggested_object_id="old_sensor", - disabled_by=None, - ) - entity_info = [ - SensorInfo( - object_id="mysensor", - key=1, - name="my sensor", - unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.DIAGNOSTIC, - icon="mdi:leaf", - ) - ] - states = [SensorState(key=1, state=50)] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("sensor.old_sensor") - assert state is not None - assert state.state == "50" - entry = entity_registry.async_get("sensor.old_sensor") - assert entry is not None - assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None - # Note that ESPHome includes the EntityInfo type in the unique id - # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" - - async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -84,7 +41,6 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index 2756aa6d251..3cff3184bf1 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -20,7 +20,6 @@ async def test_generic_event_entity( object_id="myevent", key=1, name="my event", - unique_id="my_event", event_types=["type1", "type2"], device_class=EventDeviceClass.BUTTON, ) diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index a33be1a6fca..763e95d3e6f 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -44,7 +44,6 @@ async def test_fan_entity_with_all_features_old_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=True, supports_speed=True, supports_oscillation=True, @@ -147,7 +146,6 @@ async def test_fan_entity_with_all_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supported_speed_count=4, supports_direction=True, supports_speed=True, @@ -317,7 +315,6 @@ async def test_fan_entity_with_no_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=False, supports_speed=False, supports_oscillation=False, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 4377a714b17..bf602a6fa84 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -56,7 +56,6 @@ async def test_light_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF], @@ -98,7 +97,6 @@ async def test_light_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS], @@ -226,7 +224,6 @@ async def test_light_legacy_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], @@ -282,7 +279,6 @@ async def test_light_brightness_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], @@ -358,7 +354,6 @@ async def test_light_legacy_white_converted_to_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -423,7 +418,6 @@ async def test_light_legacy_white_with_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode, color_mode_2], @@ -478,7 +472,6 @@ async def test_light_brightness_on_off_with_unknown_color_mode( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -555,7 +548,6 @@ async def test_light_on_and_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -607,7 +599,6 @@ async def test_rgb_color_temp_light( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=color_modes, @@ -698,7 +689,6 @@ async def test_light_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.ON_OFF @@ -821,7 +811,6 @@ async def test_light_rgbw( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.WHITE @@ -991,7 +980,6 @@ async def test_light_rgbww_with_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1200,7 +1188,6 @@ async def test_light_rgbww_without_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1439,7 +1426,6 @@ async def test_light_color_temp( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1514,7 +1500,6 @@ async def test_light_color_temp_no_mireds_set( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=0, max_mireds=0, supported_color_modes=[ @@ -1610,7 +1595,6 @@ async def test_light_color_temp_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1695,7 +1679,6 @@ async def test_light_rgb_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1795,7 +1778,6 @@ async def test_light_effects( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, effects=["effect1", "effect2"], @@ -1859,7 +1841,6 @@ async def test_only_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_modes], @@ -1955,7 +1936,6 @@ async def test_light_no_color_modes( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode], diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index eaa03947a7d..93e9c0704c3 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -34,7 +34,6 @@ async def test_lock_entity_no_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=False, requires_code=False, ) @@ -72,7 +71,6 @@ async def test_lock_entity_start_locked( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", ) ] states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKED)] @@ -99,7 +97,6 @@ async def test_lock_entity_supports_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=True, requires_code=True, ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 6d7a3b220d1..232f7e1f06e 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_entity( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -202,7 +201,6 @@ async def test_media_player_entity_with_source( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -318,7 +316,6 @@ async def test_media_player_proxy( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -477,7 +474,6 @@ async def test_media_player_formats_reload_preserves_data( object_id="test_media_player", key=1, name="Test Media Player", - unique_id="test_unique_id", supports_pause=True, supported_formats=supported_formats, ) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index d7a59222d47..02b58649fec 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -35,7 +35,6 @@ async def test_generic_number_entity( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -75,7 +74,6 @@ async def test_generic_number_nan( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -107,7 +105,6 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -140,7 +137,6 @@ async def test_generic_number_entity_set_when_disconnected( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index fed76ac580a..f5142367432 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -133,7 +133,6 @@ async def test_device_conflict_migration( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 6b7415889d8..14673f5ffb9 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -67,7 +67,6 @@ async def test_select_generic_entity( object_id="myselect", key=1, name="my select", - unique_id="my_select", options=["a", "b"], ) ] diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index e520b6ca259..6d3d59b9b4a 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -54,7 +54,6 @@ async def test_generic_numeric_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=50)] @@ -110,7 +109,6 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) @@ -147,7 +145,6 @@ async def test_generic_numeric_sensor_state_class_measurement( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", state_class=ESPHomeSensorStateClass.MEASUREMENT, device_class="power", unit_of_measurement="W", @@ -184,7 +181,6 @@ async def test_generic_numeric_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class="timestamp", ) ] @@ -212,7 +208,6 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) @@ -242,7 +237,6 @@ async def test_generic_numeric_sensor_no_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [] @@ -269,7 +263,6 @@ async def test_generic_numeric_sensor_nan_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=math.nan, missing_state=False)] @@ -296,7 +289,6 @@ async def test_generic_numeric_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=True, missing_state=True)] @@ -323,7 +315,6 @@ async def test_generic_text_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state="i am a teapot")] @@ -350,7 +341,6 @@ async def test_generic_text_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state=True, missing_state=True)] @@ -377,7 +367,6 @@ async def test_generic_text_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.TIMESTAMP, ) ] @@ -406,7 +395,6 @@ async def test_generic_text_sensor_device_class_date( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.DATE, ) ] @@ -435,7 +423,6 @@ async def test_generic_numeric_sensor_empty_string_uom( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", unit_of_measurement="", ) ] @@ -493,7 +480,6 @@ async def test_suggested_display_precision_by_device_class( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", accuracy_decimals=expected_precision, device_class=device_class.value, unit_of_measurement=unit_of_measurement, diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index c62101125bd..2d054a7317d 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -26,7 +26,6 @@ async def test_switch_generic_entity( object_id="myswitch", key=1, name="my switch", - unique_id="my_switch", ) ] states = [SwitchState(key=1, state=True)] @@ -78,14 +77,12 @@ async def test_switch_sub_device_non_zero_device_id( object_id="main_switch", key=1, name="Main Switch", - unique_id="main_switch_1", device_id=0, # Main device ), SwitchInfo( object_id="sub_switch", key=2, name="Sub Switch", - unique_id="sub_switch_1", device_id=11111111, # Sub-device ), ] diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index f8c1d33e224..b1e84544e3e 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -26,7 +26,6 @@ async def test_generic_text_entity( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -66,7 +65,6 @@ async def test_generic_text_entity_no_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -97,7 +95,6 @@ async def test_generic_text_entity_missing_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 75e2a0dc664..176510d4e65 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -26,7 +26,6 @@ async def test_generic_time_entity( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, hour=12, minute=34, second=56)] @@ -62,7 +61,6 @@ async def test_generic_time_missing_state( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 96b77281485..859189f5ed9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -436,7 +436,6 @@ async def test_generic_device_update_entity( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -470,7 +469,6 @@ async def test_generic_device_update_entity_has_update( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -561,7 +559,6 @@ async def test_update_entity_release_notes( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index aaa52551115..4f57a27708c 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -36,7 +36,6 @@ async def test_valve_entity( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=True, supports_stop=True, ) @@ -134,7 +133,6 @@ async def test_valve_entity_without_position( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=False, supports_stop=False, ) From 3877a6211ace42af4afb537ab3b58c6d0b69abc7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Jul 2025 19:56:19 +0200 Subject: [PATCH 0373/1118] Ensure Lokalise download runs as the same user as GitHub Actions (#149026) --- script/translations/download.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/script/translations/download.py b/script/translations/download.py index 3fa7065d058..6a0d6ba824c 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import os from pathlib import Path import re import subprocess @@ -20,13 +21,15 @@ DOWNLOAD_DIR = Path("build/translations-download").absolute() def run_download_docker(): """Run the Docker image to download the translations.""" print("Running Docker to download latest translations.") - run = subprocess.run( + result = subprocess.run( [ "docker", "run", "-v", f"{DOWNLOAD_DIR}:/opt/dest/locale", "--rm", + "--user", + f"{os.getuid()}:{os.getgid()}", f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}", # Lokalise command "lokalise2", @@ -52,7 +55,7 @@ def run_download_docker(): ) print() - if run.returncode != 0: + if result.returncode != 0: raise ExitApp("Failed to download translations") From 33cc257e759fc3bbcb2c0afc0a7e78600a9302e9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:38:53 -0400 Subject: [PATCH 0374/1118] Consolidate template integration's config schemas (#149018) --- .../template/alarm_control_panel.py | 113 ++++++++---------- .../components/template/binary_sensor.py | 51 ++++---- homeassistant/components/template/button.py | 31 ++--- homeassistant/components/template/config.py | 32 ++--- homeassistant/components/template/const.py | 14 ++- homeassistant/components/template/cover.py | 6 +- homeassistant/components/template/fan.py | 6 +- homeassistant/components/template/helpers.py | 44 +++++++ homeassistant/components/template/image.py | 26 ++-- homeassistant/components/template/light.py | 6 +- homeassistant/components/template/lock.py | 3 +- homeassistant/components/template/number.py | 64 +++++----- homeassistant/components/template/select.py | 64 ++++++---- homeassistant/components/template/sensor.py | 63 ++++++---- homeassistant/components/template/switch.py | 61 +++++----- .../components/template/template_entity.py | 12 +- homeassistant/components/template/vacuum.py | 6 +- homeassistant/components/template/weather.py | 36 +++++- 18 files changed, 385 insertions(+), 253 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index cd70a7d44e0..f95fc0dbab7 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -21,7 +21,6 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_UNIQUE_ID, @@ -31,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -43,8 +42,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -88,27 +95,28 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Alarm Control Panel" -ALARM_CONTROL_PANEL_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional( - CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name - ): cv.enum(TemplateCodeFormat), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( + TemplateCodeFormat + ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + } ) +ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) -LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, @@ -130,59 +138,29 @@ LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - LEGACY_ALARM_CONTROL_PANEL_SCHEMA + ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA ), } ) -ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( - TemplateCodeFormat - ), - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } +ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) - async_add_entities( - [ - StateAlarmControlPanelEntity( - hass, - validated_config, - config_entry.entry_id, - ) - ] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) @@ -211,11 +189,14 @@ def async_create_preview_alarm_control_panel( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateAlarmControlPanelEntity: """Create a preview alarm control panel.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA( - updated_config | {CONF_NAME: name} + return async_setup_template_preview( + hass, + name, + config, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) - return StateAlarmControlPanelEntity(hass, validated_config, None) class AbstractTemplateAlarmControlPanel( diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index caac43712a7..e8b8efbda0a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, @@ -38,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -50,8 +49,16 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_AVAILABILITY_TEMPLATE -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -64,7 +71,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), @@ -73,15 +80,17 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Required(CONF_STATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) - -BINARY_SENSOR_CONFIG_SCHEMA = BINARY_SENSOR_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } ) -LEGACY_BINARY_SENSOR_SCHEMA = vol.All( +BINARY_SENSOR_YAML_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_SCHEMA.schema +) + +BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + +BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -106,7 +115,7 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( - LEGACY_BINARY_SENSOR_SCHEMA + BINARY_SENSOR_LEGACY_YAML_SCHEMA ), } ) @@ -138,11 +147,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateBinarySensorEntity, + BINARY_SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -151,8 +161,9 @@ def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateBinarySensorEntity: """Create a preview sensor.""" - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateBinarySensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateBinarySensorEntity, BINARY_SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 26d339b7e33..d84005ccc28 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -14,9 +14,9 @@ from homeassistant.components.button import ( ButtonEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME +from homeassistant.const import CONF_DEVICE_CLASS from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -24,29 +24,31 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import async_setup_template_entry, async_setup_template_platform +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = vol.Schema( +BUTTON_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -CONFIG_BUTTON_SCHEMA = vol.Schema( +BUTTON_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -73,11 +75,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = CONFIG_BUTTON_SCHEMA(_options) - async_add_entities( - [StateButtonEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateButtonEntity, + BUTTON_CONFIG_ENTRY_SCHEMA, ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 1b3e9986d36..a3311c35563 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -102,57 +102,57 @@ CONFIG_SECTION_SCHEMA = vol.All( { vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + binary_sensor_platform.BINARY_SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA + sensor_platform.SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( cv.ensure_list, - [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_YAML_SCHEMA], ), vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_BUTTON): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] + cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), vol.Optional(DOMAIN_COVER): vol.All( - cv.ensure_list, [cover_platform.COVER_SCHEMA] + cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), vol.Optional(DOMAIN_FAN): vol.All( - cv.ensure_list, [fan_platform.FAN_SCHEMA] + cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), vol.Optional(DOMAIN_IMAGE): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] + cv.ensure_list, [image_platform.IMAGE_YAML_SCHEMA] ), vol.Optional(DOMAIN_LIGHT): vol.All( - cv.ensure_list, [light_platform.LIGHT_SCHEMA] + cv.ensure_list, [light_platform.LIGHT_YAML_SCHEMA] ), vol.Optional(DOMAIN_LOCK): vol.All( - cv.ensure_list, [lock_platform.LOCK_SCHEMA] + cv.ensure_list, [lock_platform.LOCK_YAML_SCHEMA] ), vol.Optional(DOMAIN_NUMBER): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] + cv.ensure_list, [number_platform.NUMBER_YAML_SCHEMA] ), vol.Optional(DOMAIN_SELECT): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] + cv.ensure_list, [select_platform.SELECT_YAML_SCHEMA] ), vol.Optional(DOMAIN_SENSOR): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + cv.ensure_list, [sensor_platform.SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_SWITCH): vol.All( - cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), vol.Optional(DOMAIN_VACUUM): vol.All( - cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), vol.Optional(DOMAIN_WEATHER): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + cv.ensure_list, [weather_platform.WEATHER_YAML_SCHEMA] ), }, ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 53c0fa3af13..e3e0e4fe9f5 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,6 +1,9 @@ """Constants for the Template Platform Components.""" -from homeassistant.const import Platform +import voluptuous as vol + +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" @@ -16,6 +19,15 @@ CONF_STEP = "step" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + DOMAIN = "template" PLATFORM_STORAGE_KEY = "template_platforms" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index bceac7811f4..0bbc6b77f57 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -91,7 +91,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Cover" -COVER_SCHEMA = vol.All( +COVER_YAML_SCHEMA = vol.All( vol.Schema( { vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, @@ -110,7 +110,7 @@ COVER_SCHEMA = vol.All( cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) -LEGACY_COVER_SCHEMA = vol.All( +COVER_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -134,7 +134,7 @@ LEGACY_COVER_SCHEMA = vol.All( ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 34faba353d0..13d2414aea2 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -81,7 +81,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" -FAN_SCHEMA = vol.All( +FAN_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, @@ -101,7 +101,7 @@ FAN_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) -LEGACY_FAN_SCHEMA = vol.All( +FAN_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -126,7 +126,7 @@ LEGACY_FAN_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 514255f417a..c0177e9dd5d 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -5,14 +5,19 @@ import itertools import logging from typing import Any +import voluptuous as vol + from homeassistant.components import blueprint +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, callback @@ -20,6 +25,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, AddEntitiesCallback, async_get_platforms, ) @@ -228,3 +234,41 @@ async def async_setup_template_platform( discovery_info["entities"], discovery_info["unique_id"], ) + + +async def async_setup_template_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + state_entity_cls: type[TemplateEntity], + config_schema: vol.Schema, + replace_value_template: bool = False, +) -> None: + """Setup the Template from a config entry.""" + options = dict(config_entry.options) + options.pop("template_type") + + if replace_value_template and CONF_VALUE_TEMPLATE in options: + options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) + + validated_config = config_schema(options) + + async_add_entities( + [state_entity_cls(hass, validated_config, config_entry.entry_id)] + ) + + +def async_setup_template_preview[T: TemplateEntity]( + hass: HomeAssistant, + name: str, + config: ConfigType, + state_entity_cls: type[T], + schema: vol.Schema, + replace_value_template: bool = False, +) -> T: + """Setup the Template preview.""" + if replace_value_template and CONF_VALUE_TEMPLATE in config: + config[CONF_STATE] = config.pop(CONF_VALUE_TEMPLATE) + + validated_config = schema(config | {CONF_NAME: name}) + return state_entity_cls(hass, validated_config, None) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 57e7c6ffc55..b4513fc2447 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -13,10 +13,10 @@ from homeassistant.components.image import ( ImageEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -26,8 +26,9 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .helpers import async_setup_template_platform +from .helpers import async_setup_template_entry, async_setup_template_platform from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -39,7 +40,7 @@ DEFAULT_NAME = "Template Image" GET_IMAGE_TIMEOUT = 10 -IMAGE_SCHEMA = vol.Schema( +IMAGE_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, @@ -47,14 +48,12 @@ IMAGE_SCHEMA = vol.Schema( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) -IMAGE_CONFIG_SCHEMA = vol.Schema( +IMAGE_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -81,11 +80,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = IMAGE_CONFIG_SCHEMA(_options) - async_add_entities( - [StateImageEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateImageEntity, + IMAGE_CONFIG_ENTRY_SCHEMA, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index fb97d95db3d..802fc145427 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -121,7 +121,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = vol.Schema( +LIGHT_YAML_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -147,7 +147,7 @@ LIGHT_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_LIGHT_SCHEMA = vol.All( +LIGHT_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -186,7 +186,7 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_LEGACY_YAML_SCHEMA)} ), ) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 581a037c3d7..a2f1f56bea2 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -54,7 +54,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_SCHEMA = vol.All( +LOCK_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_CODE_FORMAT): cv.template, @@ -68,7 +68,6 @@ LOCK_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) - PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e0b8e7594ce..31a6338f594 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -18,14 +18,13 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -34,8 +33,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -45,30 +52,31 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = vol.Schema( +NUMBER_COMMON_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -NUMBER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, vol.Required(CONF_STATE): cv.template, vol.Required(CONF_STEP): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_MIN): cv.template, - vol.Optional(CONF_MAX): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) +NUMBER_YAML_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(NUMBER_COMMON_SCHEMA.schema) +) + +NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -94,11 +102,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities( - [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateNumberEntity, + NUMBER_CONFIG_ENTRY_SCHEMA, ) @@ -107,8 +116,9 @@ def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateNumberEntity: """Create a preview number.""" - validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateNumberEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateNumberEntity, NUMBER_CONFIG_ENTRY_SCHEMA + ) class StateNumberEntity(TemplateEntity, NumberEntity): diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 4273af6db28..0ad99cd6ae8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -15,9 +15,9 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -27,8 +27,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -39,26 +47,28 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = vol.Schema( +SELECT_COMMON_SCHEMA = vol.Schema( { - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - - -SELECT_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_OPTIONS): cv.template, + vol.Optional(ATTR_OPTIONS): cv.template, vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_STATE): cv.template, } ) +SELECT_YAML_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(SELECT_COMMON_SCHEMA.schema) +) + +SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -84,10 +94,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SELECT_CONFIG_SCHEMA(_options) - async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateSelect, + SELECT_CONFIG_ENTRY_SCHEMA, + ) @callback @@ -95,8 +108,9 @@ def async_create_preview_select( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> TemplateSelect: """Create a preview select.""" - validated_config = SELECT_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateSelect(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, TemplateSelect, SELECT_CONFIG_ENTRY_SCHEMA + ) class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 6fc0588d9c7..ff956c50c6e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -25,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -43,19 +43,26 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity LEGACY_FIELDS = { @@ -77,29 +84,31 @@ def validate_last_reset(val): return val -SENSOR_SCHEMA = vol.All( +SENSOR_COMMON_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + +SENSOR_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Required(CONF_STATE): cv.template, vol.Optional(ATTR_LAST_RESET): cv.template, } ) - .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(SENSOR_COMMON_SCHEMA.schema) .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), validate_last_reset, ) - -SENSOR_CONFIG_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } - ).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema), +SENSOR_CONFIG_ENTRY_SCHEMA = SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -LEGACY_SENSOR_SCHEMA = vol.All( +SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -141,7 +150,9 @@ PLATFORM_SCHEMA = vol.All( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( + SENSOR_LEGACY_YAML_SCHEMA + ), } ), extra_validation_checks, @@ -176,11 +187,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSensorEntity, + SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -189,8 +201,9 @@ def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSensorEntity: """Create a preview sensor.""" - validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateSensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateSensorEntity, SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateSensorEntity(TemplateEntity, SensorEntity): diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 7c1abd6d852..b1d72084ae7 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_SWITCHES, @@ -29,7 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -39,8 +38,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, @@ -55,16 +59,19 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Switch" - -SWITCH_SCHEMA = vol.Schema( +SWITCH_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +) -LEGACY_SWITCH_SCHEMA = vol.All( +SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) + +SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -79,17 +86,11 @@ LEGACY_SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_LEGACY_YAML_SCHEMA)} ) -SWITCH_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } +SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -129,12 +130,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, ) @@ -143,9 +145,14 @@ def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSwitchEntity: """Create a preview switch.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return StateSwitchEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, + name, + config, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, + ) class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index b5081189cf3..ae473854502 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON, CONF_ICON_TEMPLATE, @@ -30,7 +31,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, @@ -46,7 +47,6 @@ from homeassistant.helpers.template import ( result_as_boolean, ) from homeassistant.helpers.trigger_template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, make_template_entity_base_schema, ) from homeassistant.helpers.typing import ConfigType @@ -57,6 +57,7 @@ from .const import ( CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, + TEMPLATE_ENTITY_BASE_SCHEMA, ) from .entity import AbstractTemplateEntity @@ -91,6 +92,13 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) ) +TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + def make_template_entity_common_modern_schema( default_name: str, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 143eb837bb5..0056eca9b99 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -76,7 +76,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_SCHEMA = vol.All( +VACUUM_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, @@ -94,7 +94,7 @@ VACUUM_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) -LEGACY_VACUUM_SCHEMA = vol.All( +VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -119,7 +119,7 @@ LEGACY_VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 671a2ad0bac..15c6fb4db9e 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,7 +31,12 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template @@ -100,7 +105,7 @@ CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" DEFAULT_NAME = "Template Weather" -WEATHER_SCHEMA = vol.Schema( +WEATHER_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, @@ -126,7 +131,32 @@ WEATHER_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +PLATFORM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + } +).extend(WEATHER_PLATFORM_SCHEMA.schema) async def async_setup_platform( From 380c7379018ebdcd25a8240cc5b588d00bf3f55e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 18 Jul 2025 20:41:59 +0200 Subject: [PATCH 0375/1118] Add reorder option to entity selector (#149002) --- homeassistant/helpers/selector.py | 2 ++ tests/components/blueprint/snapshots/test_importer.ambr | 2 ++ tests/helpers/test_selector.py | 5 +++++ tests/helpers/test_service.py | 2 ++ 4 files changed, 11 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 7bd1ee9ddf3..9eaedc6f5ef 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -813,6 +813,7 @@ class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total exclude_entities: list[str] include_entities: list[str] multiple: bool + reorder: bool filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -829,6 +830,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 38cb3b485d4..fdfd3f6b285 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -203,6 +203,7 @@ 'light', ]), 'multiple': False, + 'reorder': False, }), }), }), @@ -217,6 +218,7 @@ 'binary_sensor', ]), 'multiple': False, + 'reorder': False, }), }), }), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index dc25206177b..9e8f1b15311 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -231,6 +231,11 @@ def test_device_selector_schema_error(schema) -> None: ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"multiple": True, "reorder": True}, + ((["sensor.abc123", "sensor.def456"],)), + (None, "abc123", ["sensor.abc123", None]), + ), ( {"filter": {"domain": "light"}}, ("light.abc123", FAKE_UUID), diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f4d0846c262..8f094536988 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1091,6 +1091,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: } ], "multiple": False, + "reorder": False, }, }, }, @@ -1113,6 +1114,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: } ], "multiple": False, + "reorder": False, }, }, }, From f90e06fde1c8e61b5f02f3c20853457078c625df Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 18 Jul 2025 22:27:48 -0700 Subject: [PATCH 0376/1118] Add attachment support in ollama ai task (#148981) --- homeassistant/components/ollama/ai_task.py | 5 +- homeassistant/components/ollama/entity.py | 9 ++ homeassistant/components/ollama/strings.json | 5 + tests/components/ollama/test_ai_task.py | 116 ++++++++++++++++++- 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py index d796b28aac8..43c50abd16a 100644 --- a/homeassistant/components/ollama/ai_task.py +++ b/homeassistant/components/ollama/ai_task.py @@ -39,7 +39,10 @@ class OllamaTaskEntity( ): """Ollama AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 4122d0c67d8..b2f0ebbb7b8 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -106,9 +106,18 @@ def _convert_content( ], ) if isinstance(chat_content, conversation.UserContent): + images: list[ollama.Image] = [] + for attachment in chat_content.attachments or (): + if not attachment.mime_type.startswith("image/"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_attachment_type", + ) + images.append(ollama.Image(value=attachment.path)) return ollama.Message( role=MessageRole.USER.value, content=chat_content.content, + images=images or None, ) if isinstance(chat_content, conversation.SystemContent): return ollama.Message( diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 87d2048a966..4f3cb3c30c0 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -94,5 +94,10 @@ "download": "[%key:component::ollama::config_subentries::conversation::progress::download%]" } } + }, + "exceptions": { + "unsupported_attachment_type": { + "message": "Ollama only supports image attachments in user content, but received non-image attachment." + } } } diff --git a/tests/components/ollama/test_ai_task.py b/tests/components/ollama/test_ai_task.py index ee812e7b316..cb639db0f8e 100644 --- a/tests/components/ollama/test_ai_task.py +++ b/tests/components/ollama/test_ai_task.py @@ -1,11 +1,13 @@ """Test AI Task platform of Ollama integration.""" +from pathlib import Path from unittest.mock import patch +import ollama import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -243,3 +245,115 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachment( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat, + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Generated test data" + + assert mock_chat.call_count == 1 + messages = mock_chat.call_args[1]["messages"] + assert len(messages) == 2 + chat_message = messages[1] + assert chat_message.role == "user" + assert chat_message.content == "Generate test data" + assert chat_message.images == [ + ollama.Image(value=Path("doorbell_snapshot.jpg")), + ] + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_unsupported_file_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises( + HomeAssistantError, + match="Ollama only supports image attachments in user content", + ), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) From 6f59aaebdd0549fe6de3bee39a00a8e13ce221c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 13:14:20 +0200 Subject: [PATCH 0377/1118] Add extended class for OptionsFlow that automatically reloads (#146910) Co-authored-by: Erik Montnemery --- homeassistant/config_entries.py | 29 ++++++++++- tests/test_config_entries.py | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e76b7ae099f..1c4f2b51ac7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3491,7 +3491,22 @@ class OptionsFlowManager( entry = self.hass.config_entries.async_get_known_entry(flow.handler) if result["data"] is not None: - self.hass.config_entries.async_update_entry(entry, options=result["data"]) + automatic_reload = False + if isinstance(flow, OptionsFlowWithReload): + automatic_reload = flow.automatic_reload + + if automatic_reload and entry.update_listeners: + raise ValueError( + "Config entry update listeners should not be used with OptionsFlowWithReload" + ) + + if ( + self.hass.config_entries.async_update_entry( + entry, options=result["data"] + ) + and automatic_reload is True + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) result["result"] = True return result @@ -3600,6 +3615,18 @@ class OptionsFlowWithConfigEntry(OptionsFlow): return self._options +class OptionsFlowWithReload(OptionsFlow): + """Automatic reloading class for config options flows. + + Triggers an automatic reload of the config entry when the flow ends with + calling `async_create_entry` with changed options. + It's not allowed to use this class if the integration uses config entry + update listeners. + """ + + automatic_reload: bool = True + + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7fb632e18b5..9666e8ba1c4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -15,6 +15,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant import config_entries, data_entry_flow, loader from homeassistant.config_entries import ConfigEntry @@ -8656,6 +8657,95 @@ async def test_options_flow_config_entry( assert result["reason"] == "abort" +@pytest.mark.parametrize( + ( + "option_flow_base_class", + "number_of_update_listeners", + "expected_configure_result", + "expected_number_of_unloads", + ), + [ + (config_entries.OptionsFlow, 0, does_not_raise(), 0), + (config_entries.OptionsFlowWithReload, 0, does_not_raise(), 1), + (config_entries.OptionsFlow, 1, does_not_raise(), 0), + ( + config_entries.OptionsFlowWithReload, + 1, + pytest.raises( + ValueError, + match="Config entry update listeners should not be used with OptionsFlowWithReload", + ), + 0, + ), + ], +) +async def test_options_flow_automatic_reload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + option_flow_base_class: type[config_entries.OptionsFlow], + number_of_update_listeners: int, + expected_configure_result: AbstractContextManager, + expected_number_of_unloads: int, +) -> None: + """Test options flow with automatic reload when updated.""" + original_entry = MockConfigEntry( + domain="test", title="Test", data={}, options={"test": "first"} + ) + original_entry.add_to_hass(hass) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + for _ in range(number_of_update_listeners): + entry.add_update_listener(Mock()) + return True + + unload_entry_mock = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry, + async_unload_entry=unload_entry_mock, + ), + ) + mock_platform(hass, "test.config_flow", None) + + await hass.config_entries.async_setup(original_entry.entry_id) + assert original_entry.state is config_entries.ConfigEntryState.LOADED + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(option_flow_base_class): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + return self.async_show_form( + step_id="init", data_schema=vol.Schema({"test": str}) + ) + + return _OptionsFlow() + + with mock_config_flow("test", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + with expected_configure_result: + await hass.config_entries.options.async_configure( + result["flow_id"], {"test": "updated"} + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(unload_entry_mock.mock_calls) == expected_number_of_unloads + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_options_flow_deprecated_config_entry_setter( From 3a6f23b95fdf87af8221d77c5595b88a2a34ee5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jul 2025 01:53:51 -1000 Subject: [PATCH 0378/1118] Bump aioesphomeapi to 37.0.1 (#149035) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 903aaea9980..bb1f2d28457 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==36.0.1", + "aioesphomeapi==37.0.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 03019fcc39e..1529fdd306f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==36.0.1 +aioesphomeapi==37.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0042ef7aa34..ac1be38ee4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==36.0.1 +aioesphomeapi==37.0.1 # homeassistant.components.flo aioflo==2021.11.0 From b3bd882a8067df2a13c582d1b3f2c56fd890aaba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:09:54 +0200 Subject: [PATCH 0379/1118] Use OptionsFlowWithReload in Trafikverket Train (#149042) --- homeassistant/components/trafikverket_train/__init__.py | 6 ------ homeassistant/components/trafikverket_train/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 19f88817e71..7cdb0c02f5b 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -53,11 +52,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index fb39e14815e..2328a7126fd 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -329,7 +329,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): ) -class TVTrainOptionsFlowHandler(OptionsFlow): +class TVTrainOptionsFlowHandler(OptionsFlowWithReload): """Handle Trafikverket Train options.""" async def async_step_init( From d7d2013ec8ea95ae52a4f2548c24138a71c3b315 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:12:25 +0200 Subject: [PATCH 0380/1118] Use OptionsFlowWithReload in sql (#149047) --- homeassistant/components/sql/__init__.py | 7 ------- homeassistant/components/sql/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index e3e6c699d03..33ed64be2bf 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -87,11 +87,6 @@ def remove_configured_db_url_if_not_needed( ) -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -115,8 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 4fe04f2401c..37a6f9ef104 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -209,7 +209,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(OptionsFlow): +class SQLOptionsFlowHandler(OptionsFlowWithReload): """Handle SQL options.""" async def async_step_init( From 284b90d502312bab830c136856797dc7583a2397 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:14:13 +0200 Subject: [PATCH 0381/1118] Use OptionsFlowWithReload in yeelight (#149045) --- homeassistant/components/yeelight/__init__.py | 8 -------- homeassistant/components/yeelight/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 0b3ceaf2aee..cb24edae1fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -232,9 +232,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Wait to install the reload listener until everything was successfully initialized - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -245,11 +242,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 15975ba22bd..cc3ab35f684 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback @@ -298,7 +298,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return MODEL_UNKNOWN -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Yeelight.""" async def async_step_init( From be6743d4fdbd2a698edb5880fce517943fe6028c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:14:38 +0200 Subject: [PATCH 0382/1118] Use OptionsFlowWithReload in yale_smart_alarm (#149040) --- homeassistant/components/yale_smart_alarm/__init__.py | 6 ------ homeassistant/components/yale_smart_alarm/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index d67e136be4a..5c481719cc9 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -22,16 +22,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 1aaad2aa63a..d8c1fc80f8f 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -171,7 +171,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): ) -class YaleOptionsFlowHandler(OptionsFlow): +class YaleOptionsFlowHandler(OptionsFlowWithReload): """Handle Yale options.""" async def async_step_init( From 8a2493e9d24719538173dd6da3424b220313e5b6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:26:54 +0200 Subject: [PATCH 0383/1118] Use OptionsFlowWithReload in Workday (#149043) --- homeassistant/components/workday/__init__.py | 6 ------ homeassistant/components/workday/config_flow.py | 4 ++-- tests/components/workday/test_init.py | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 60a0489ec5c..0df4224a4ca 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -94,16 +94,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options[CONF_LANGUAGE] = default_language hass.config_entries.async_update_entry(entry, options=new_options) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Workday config entry.""" diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7a8a8181a9f..1d91e1d5ae3 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -311,7 +311,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class WorkdayOptionsFlowHandler(OptionsFlow): +class WorkdayOptionsFlowHandler(OptionsFlowWithReload): """Handle Workday options.""" async def async_step_init( diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index f288c340d9f..653b6810197 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -45,6 +45,7 @@ async def test_update_options( new_options["add_holidays"] = ["2023-04-12"] hass.config_entries.async_update_entry(entry, options=new_options) + await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() entry_check = hass.config_entries.async_get_entry("1") From 665991a3c17f298e20112dcf61b92fba593bf16f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:27:46 +0200 Subject: [PATCH 0384/1118] Use OptionsFlowWithReload in wled (#149046) --- homeassistant/components/wled/__init__.py | 8 -------- homeassistant/components/wled/config_flow.py | 4 ++-- tests/components/wled/test_light.py | 1 + 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index b4834347694..c3917507fb9 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -48,9 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when its updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True @@ -65,8 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2e0b7b1c793..e80760508a0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -120,7 +120,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlow): +class WLEDOptionsFlowHandler(OptionsFlowWithReload): """Handle WLED options.""" async def async_step_init( diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 57635a8cb74..90e731f3fe9 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -373,6 +373,7 @@ async def test_single_segment_with_keep_main_light( hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) + await hass.config_entries.async_reload(init_integration.entry_id) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light_main")) From 31167f5da71db64f1d1dd57177bf4f221e824f77 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 15:16:56 +0200 Subject: [PATCH 0385/1118] Use OptionsFlowWithReload in webostv (#149054) --- homeassistant/components/webostv/__init__.py | 7 ------- homeassistant/components/webostv/config_flow.py | 10 +++++++--- tests/components/webostv/test_init.py | 1 + 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index c1a1c698f92..fb729707154 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b ) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" client.clear_state_update_callbacks() @@ -88,11 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b return True -async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 2af38cb3d17..44711c2b456 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,7 +9,11 @@ from urllib.parse import urlparse from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -60,7 +64,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -197,7 +201,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" def __init__(self, config_entry: WebOsTvConfigEntry) -> None: diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index cd8f443c8fd..d7fb12c2848 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -54,6 +54,7 @@ async def test_update_options(hass: HomeAssistant, client) -> None: new_options = config_entry.options.copy() new_options[CONF_SOURCES] = ["Input02", "Live TV"] hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED From 7202203f35779f8515b5d85c32283998987bd0bc Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:33:34 +0100 Subject: [PATCH 0386/1118] Update bool test in coordinator platform for Squeezebox (#149073) --- homeassistant/components/squeezebox/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 6582f143e79..8bfb952b680 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -111,7 +111,7 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Only update players available at last update, unavailable players are rediscovered instead await self.player.async_update() - if self.player.connected is False: + if not self.player.connected: _LOGGER.info("Player %s is not available", self.name) self.available = False From 13434012e7e8bc50ce91f6946704f781eb0adfb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:37:37 +0200 Subject: [PATCH 0387/1118] Use OptionsFlowWithReload in netgear (#149069) --- homeassistant/components/netgear/__init__.py | 7 ------- homeassistant/components/netgear/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index fa18c3510ba..9aafa482faf 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: @@ -194,11 +192,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index a0a5b76eee5..3386d07cc6d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -65,7 +65,7 @@ def _ordered_shared_schema(schema_input): } -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 290f19dbd99e6997f4c8f82c9fb1dbe1fb669d2e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:38:28 +0200 Subject: [PATCH 0388/1118] Use OptionsFlowWithReload in motion_blinds (#149070) --- homeassistant/components/motion_blinds/__init__.py | 7 ------- homeassistant/components/motion_blinds/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 2abcc273e23..9c4d1a97f00 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -145,8 +143,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Stop_listen() return unload_ok - - -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 954f9e25c21..8323c0e1995 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback @@ -38,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 12193587c9cf2aab3bc74279a8cd5d1df548ee34 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:39:38 +0200 Subject: [PATCH 0389/1118] Use OptionsFlowWithReload in fritzbox_callmonitor (#149071) --- homeassistant/components/fritzbox_callmonitor/__init__.py | 8 -------- .../components/fritzbox_callmonitor/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index b1b5db48216..ea4bf46f09c 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -48,7 +48,6 @@ async def async_setup_entry( raise ConfigEntryNotReady from ex config_entry.runtime_data = fritzbox_phonebook - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -59,10 +58,3 @@ async def async_unload_entry( ) -> bool: """Unloading the fritzbox_callmonitor platforms.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry -) -> None: - """Update listener to reload after option has changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 8435eff3e18..25e25336d57 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback @@ -263,7 +263,7 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): +class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlowWithReload): """Handle a fritzbox_callmonitor options flow.""" @classmethod From 360da4386858dcdb29cbb9908f0257248b052eb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:40:32 +0200 Subject: [PATCH 0390/1118] Use OptionsFlowWithReload in nina (#149068) --- homeassistant/components/nina/__init__.py | 7 ------- homeassistant/components/nina/config_flow.py | 6 +++--- tests/components/nina/test_config_flow.py | 4 ---- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index e074f7ad000..f9b23faa234 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -49,8 +47,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 24c016e5e64..f7bc0914481 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -165,8 +165,8 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle an option flow for NINA.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 309c8860c20..06eb94d59d0 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -323,9 +323,6 @@ async def test_options_flow_entity_removal( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ), - patch( - "homeassistant.components.nina._async_update_listener" - ) as mock_update_listener, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -352,4 +349,3 @@ async def test_options_flow_entity_removal( ) assert len(entries) == 2 - assert len(mock_update_listener.mock_calls) == 1 From 676a931c4800e37826e04eedf3b16face4bd92b2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:40:57 +0200 Subject: [PATCH 0391/1118] Use OptionsFlowWithReload in nmap_tracker (#149067) --- homeassistant/components/nmap_tracker/__init__.py | 6 ------ homeassistant/components/nmap_tracker/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 72bf9284573..2aa77e09d16 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -88,16 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 1f436edd60c..e3d1ecbdb14 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -138,7 +138,7 @@ async def _async_build_schema_with_user_input( return vol.Schema(schema) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for homekit.""" def __init__(self, config_entry: ConfigEntry) -> None: From 440a20340e9d22b64bffe9658526552b7a61e766 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:41:38 +0200 Subject: [PATCH 0392/1118] Use OptionsFlowWithReload in nobo_hub (#149066) --- homeassistant/components/nobo_hub/__init__.py | 9 --------- homeassistant/components/nobo_hub/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 3bbf46f0264..7c886c534cb 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -42,8 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - await hub.start() return True @@ -58,10 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def options_update_listener( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 7e1ae4c1d9b..05ece456f15 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import callback @@ -173,7 +173,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -187,7 +187,7 @@ class NoboHubConnectError(HomeAssistantError): self.msg = msg -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: From 05f686cb8674ff09be24311308e756a3f505e50b Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:42:21 +0100 Subject: [PATCH 0393/1118] Update comments in 3 Squeezebox platforms (#149065) --- .../components/squeezebox/binary_sensor.py | 2 +- homeassistant/components/squeezebox/media_player.py | 13 ++++++------- homeassistant/components/squeezebox/sensor.py | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index 1045e526ee3..ea305d71f99 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index dc426d76588..0dbc1b96b0c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -226,10 +226,7 @@ def get_announce_timeout(extra: dict) -> int | None: class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): - """Representation of the media player features of a SqueezeBox device. - - Wraps a pysqueezebox.Player() object. - """ + """Representation of the media player features of a SqueezeBox device.""" _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA @@ -286,9 +283,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: - """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( # type: ignore[no-any-return] - CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + """Return the max number of items to return from browse.""" + return int( + self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) ) @property diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 11c169910dc..79390910ef7 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( class ServerStatusSensor(LMSStatusEntity, SensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def native_value(self) -> StateType: From ab964c8bcabb88e5a0369bc93053ee5ffeb1186f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:43:49 +0200 Subject: [PATCH 0394/1118] Use OptionsFlowWithReload in tankerkoenig (#149063) --- homeassistant/components/tankerkoenig/__init__.py | 9 --------- homeassistant/components/tankerkoenig/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index b2b60db9675..2a85b1f31e1 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,8 +23,6 @@ async def async_setup_entry( entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -35,10 +33,3 @@ async def async_unload_entry( ) -> bool: """Unload Tankerkoenig config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener( - hass: HomeAssistant, entry: TankerkoenigConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b269eaaaf55..9aeb0a80173 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -229,7 +229,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" def __init__(self) -> None: From ff14f6b823a1d79cdb4a9d38167b4192596a7131 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:44:51 +0200 Subject: [PATCH 0395/1118] Use OptionsFlowWithReload in somfy_mylink (#149062) --- .../components/somfy_mylink/__init__.py | 17 +---------------- .../components/somfy_mylink/config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 89796f5ce46..fdbaaf9f427 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -11,8 +11,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS -UNDO_UPDATE_LISTENER = "undo_update_listener" - _LOGGER = logging.getLogger(__name__) @@ -44,12 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - undo_listener = entry.add_update_listener(_async_update_listener) - hass.data[DOMAIN][entry.entry_id] = { DATA_SOMFY_MYLINK: somfy_mylink, MYLINK_STATUS: mylink_status, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,18 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index a806d581aec..91cfae87347 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -125,7 +125,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" def __init__(self, config_entry: ConfigEntry) -> None: From cb4d17b24f0df138166ab4e7166c41e10fd7c4f4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:45:39 +0200 Subject: [PATCH 0396/1118] Use OptionsFlowWithReload in Ping (#149061) --- homeassistant/components/ping/__init__.py | 6 ------ homeassistant/components/ping/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 14203541359..f1d0113ac5e 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -50,16 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_reload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 27cb3f62bcd..d66f4beb8e5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -71,12 +71,12 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Create the options flow.""" return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Ping.""" async def async_step_init( From 69c26e5f1f8f97527b72499ecdadf25fffa658d3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:46:30 +0200 Subject: [PATCH 0397/1118] Use OptionsFlowWithReload in dnsip (#149059) --- homeassistant/components/dnsip/__init__.py | 6 ------ homeassistant/components/dnsip/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 37e0f60849f..3487ce83c7b 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -13,15 +13,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DNS IP from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload dnsip config entry.""" diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index ab1ca42acd3..0ea2a9d092b 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ) -class DnsIPOptionsFlowHandler(OptionsFlow): +class DnsIPOptionsFlowHandler(OptionsFlowWithReload): """Handle a option config flow for dnsip integration.""" async def async_step_init( From 22b35030a988344c12300d91bcd8e5182d8046b2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:47:09 +0200 Subject: [PATCH 0398/1118] Use OptionsFlowWithReload in analytics_insight (#149056) --- homeassistant/components/analytics_insights/__init__.py | 8 -------- .../components/analytics_insights/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index ee7f6611c65..2d66d5149cf 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -55,7 +55,6 @@ async def async_setup_entry( entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -65,10 +64,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b2648f7c13c..d5c0c4a7f73 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -11,7 +11,11 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -129,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload): """Handle Homeassistant Analytics options.""" async def async_step_init( From b9d19ffb296791215e58158948d446205fe6ee63 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:48:23 +0200 Subject: [PATCH 0399/1118] Use OptionsFlowWithReload in vera (#149055) --- homeassistant/components/vera/__init__.py | 6 ------ homeassistant/components/vera/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index b8f0b702ebe..aedc174cb6d 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -143,7 +143,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -161,11 +160,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def map_vera_device( vera_device: veraApi.VeraDevice, remap: list[int] ) -> Platform | None: diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index f2b182cc270..f02549e7857 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback @@ -73,7 +73,7 @@ def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 1bbd07fe48a1a44fbe99fe53ca4497625c22d44a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:48:53 +0200 Subject: [PATCH 0400/1118] Use OptionsFlowWithReload in wiffi (#149053) --- homeassistant/components/wiffi/__init__.py | 7 ------- homeassistant/components/wiffi/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 6cf216011f2..b6811190a27 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -29,8 +29,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up wiffi from a config entry, config_entry contains data from config entry database.""" - if not entry.update_listeners: - entry.add_update_listener(async_update_options) # create api object api = WiffiIntegrationApi(hass) @@ -53,11 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" api: WiffiIntegrationApi = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 308923597cd..c40bd5519e0 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PORT, CONF_TIMEOUT from homeassistant.core import callback @@ -76,7 +76,7 @@ class WiffiFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Wiffi server setup option flow.""" async def async_step_init( From 4a5e193ebbcad62c28bca17f1c2e9013d84a6d22 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:49:19 +0200 Subject: [PATCH 0401/1118] Use OptionsFlowWithReload in ws66i (#149052) --- homeassistant/components/ws66i/__init__.py | 6 ------ homeassistant/components/ws66i/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 32c6a11f25c..23a27adeb69 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -100,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Close the WS66i connection to the amplifier.""" ws66i.close() - entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) ) @@ -119,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 120b7738d2e..e70dbd4e8d7 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant, callback @@ -142,7 +142,7 @@ def _key_for_source( ) -class Ws66iOptionsFlowHandler(OptionsFlow): +class Ws66iOptionsFlowHandler(OptionsFlowWithReload): """Handle a WS66i options flow.""" async def async_step_init( From dba3d98a2b8fb094c78f728972b402c8ced43bd9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:50:13 +0200 Subject: [PATCH 0402/1118] Use OptionsFlowWithReload in xiaomi_miio (#149051) --- homeassistant/components/xiaomi_miio/__init__.py | 11 ----------- homeassistant/components/xiaomi_miio/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0e28a2900bb..8db5273174b 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -466,8 +466,6 @@ async def async_setup_gateway_entry( await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_setup_device_entry( hass: HomeAssistant, entry: XiaomiMiioConfigEntry @@ -481,8 +479,6 @@ async def async_setup_device_entry( await hass.config_entries.async_forward_entry_setups(entry, platforms) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -493,10 +489,3 @@ async def async_unload_entry( platforms = get_platforms(config_entry) return await hass.config_entries.async_unload_platforms(config_entry, platforms) - - -async def update_listener( - hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b8d8b028006..95eabb0188c 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,11 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -56,7 +60,7 @@ DEVICE_CLOUD_CONFIG = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From c15bf097f0f86a6c6a1e4c77a3e79277f6f43cf1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:50:41 +0200 Subject: [PATCH 0403/1118] Use OptionsFlowWithReload in airnow (#149049) --- homeassistant/components/airnow/__init__.py | 8 -------- homeassistant/components/airnow/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 6fb7e90502f..2881469b968 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -45,9 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo # Store Entity and Initialize Platforms entry.runtime_data = coordinator - # Listen for option changes - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean up unused device entries with no entities @@ -88,8 +85,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 7cd113125a8..661e1b0a298 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): return AirNowOptionsFlowHandler() -class AirNowOptionsFlowHandler(OptionsFlow): +class AirNowOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for AirNow.""" async def async_step_init( From 7e04a7ec19a25651597a987ab1c7b7e7acc15f17 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 17:40:16 +0200 Subject: [PATCH 0404/1118] Use OptionsFlowWithReload in unifiprotect (#149064) --- .../components/unifiprotect/__init__.py | 6 ------ .../components/unifiprotect/config_flow.py | 6 +++--- tests/components/unifiprotect/test_init.py | 17 ----------------- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 2d75010b4e5..440250d45a3 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -114,7 +114,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) entry.runtime_data = data_service - entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) @@ -139,11 +138,6 @@ async def _async_setup_entry( hass.http.register_view(VideoEventProxyView(hass)) -async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Unload UniFi Protect config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 9f7f4bccd7f..c83b3f11010 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -223,7 +223,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -372,7 +372,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3064c66f009..3156327f1a5 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -12,7 +12,6 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, CONF_ALLOW_EA, - CONF_DISABLE_RTSP, DOMAIN, ) from homeassistant.components.unifiprotect.data import ( @@ -87,22 +86,6 @@ async def test_setup_multiple( assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture) -> None: - """Test updating entry reload entry.""" - - await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() - assert ufp.entry.state is ConfigEntryState.LOADED - - options = dict(ufp.entry.options) - options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(ufp.entry, options=options) - await hass.async_block_till_done() - - assert ufp.entry.state is ConfigEntryState.LOADED - assert ufp.api.async_disconnect_ws.called - - async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light) -> None: """Test unloading of unifiprotect entry.""" From b3f049676da623e0a75d4d7bac9374b5f864e9c3 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:17:34 +0100 Subject: [PATCH 0405/1118] Move Squeezebox registry tests to test_init (#149050) --- .../squeezebox/snapshots/test_init.ambr | 79 +++++++++++++++++++ .../snapshots/test_media_player.ambr | 78 ------------------ tests/components/squeezebox/test_init.py | 32 +++++++- .../squeezebox/test_media_player.py | 25 ------ 4 files changed, 110 insertions(+), 104 deletions(-) create mode 100644 tests/components/squeezebox/snapshots/test_init.ambr diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr new file mode 100644 index 00000000000..3fc65be834a --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ralph Irving & Adrian Smith', + 'model': 'SqueezeLite', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- +# name: test_device_registry_server_merged + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + '12345678-1234-1234-1234-123456789012', + ), + tuple( + 'squeezebox', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', + 'model': 'Lyrion Music Server/SqueezeLite', + 'model_id': 'LMS', + 'name': '1.2.3.4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index d86c839019c..183b5ca767f 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -1,82 +1,4 @@ # serializer version: 1 -# name: test_device_registry - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Ralph Irving & Adrian Smith', - 'model': 'SqueezeLite', - 'model_id': None, - 'name': 'Test Player', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- -# name: test_device_registry_server_merged - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - '12345678-1234-1234-1234-123456789012', - ), - tuple( - 'squeezebox', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', - 'model': 'Lyrion Music Server/SqueezeLite', - 'model_id': 'LMS', - 'name': '1.2.3.4', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- # name: test_entity_registry[media_player.test_player-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index f70782b13da..5cb7e19abb5 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -1,10 +1,16 @@ """Test squeezebox initialization.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from .conftest import TEST_MAC from tests.common import MockConfigEntry @@ -82,3 +88,27 @@ async def test_init_missing_uuid( mock_async_query.assert_called_once_with( "serverstatus", "-", "-", "prefs:libraryname" ) + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) + assert reg_device is not None + assert reg_device == snapshot + + +async def test_device_registry_server_merged( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_players: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) + assert reg_device is not None + assert reg_device == snapshot diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index e1f480e33a0..1986831d827 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow @@ -82,30 +81,6 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_device_registry( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_player: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) - assert reg_device is not None - assert reg_device == snapshot - - -async def test_device_registry_server_merged( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_players: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) - assert reg_device is not None - assert reg_device == snapshot - - async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, From 0cfb395ab50d0e97847ef851822b3b368782faa7 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:20:11 +0100 Subject: [PATCH 0406/1118] Remove unnecessary getattr from init for Squeezebox (#149077) --- homeassistant/components/squeezebox/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index c6cb04b5ffb..2bd845923fc 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -112,9 +112,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - if not status: # pysqueezebox's async_query returns None on various issues, # including HTTP errors where it sets lms.http_status. - http_status = getattr(lms, "http_status", "N/A") - if http_status == HTTPStatus.UNAUTHORIZED: + if lms.http_status == HTTPStatus.UNAUTHORIZED: _LOGGER.warning("Authentication failed for Squeezebox server %s", host) raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -128,14 +127,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - _LOGGER.warning( "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", host, - http_status, + lms.http_status, ) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="init_get_status_failed", translation_placeholders={ "host": str(host), - "http_status": str(http_status), + "http_status": str(lms.http_status), }, ) From a50d926e2abefbf9c8ecf92eaae71857f31088c9 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:30:15 +0100 Subject: [PATCH 0407/1118] Check for error in test_squeezebox_play_media_with_announce_volume_invalid for Squeezebox (#149044) --- tests/components/squeezebox/test_media_player.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 1986831d827..5cd007d1267 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -510,7 +510,10 @@ async def test_squeezebox_play_media_with_announce_volume_invalid( hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int ) -> None: """Test play service call with announce and volume zero.""" - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match="announce_volume must be a number greater than 0 and less than or equal to 1", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, From 7dfb54c8e86dd4e10af01bb3a3e91468c61d8131 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:30:40 +0100 Subject: [PATCH 0408/1118] Paramaterize test for on/off for Squeezebox (#149048) --- .../squeezebox/test_media_player.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 5cd007d1267..440f682370b 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -145,30 +145,21 @@ async def test_squeezebox_player_rediscovery( assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE -async def test_squeezebox_turn_on( - hass: HomeAssistant, configured_player: MagicMock +@pytest.mark.parametrize( + ("service", "state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_squeezebox_turn_on_off( + hass: HomeAssistant, configured_player: MagicMock, service: str, state: bool ) -> None: """Test turn on service call.""" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_power.assert_called_once_with(True) - - -async def test_squeezebox_turn_off( - hass: HomeAssistant, configured_player: MagicMock -) -> None: - """Test turn off service call.""" - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "media_player.test_player"}, - blocking=True, - ) - configured_player.async_set_power.assert_called_once_with(False) + configured_player.async_set_power.assert_called_once_with(state) async def test_squeezebox_state( From 2577d9f108ef44e932b42dff575b645e904de382 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 19 Jul 2025 10:49:14 -0700 Subject: [PATCH 0409/1118] Fix a bug in rainbird device migration that results in additional devices (#149078) --- homeassistant/components/rainbird/__init__.py | 3 + tests/components/rainbird/test_init.py | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f9cd751a81e..e986cc302ae 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -218,6 +218,9 @@ def _async_fix_device_id( for device_entry in device_entries: unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry + if unique_id.startswith(mac_address): + # Already in the correct format + continue if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 01e0c4458e4..520f8578c6e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -449,3 +449,75 @@ async def test_fix_duplicate_device_ids( assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} assert device_entry.name_by_user == expected_device_name assert device_entry.disabled_by == expected_disabled_by + + +async def test_reload_migration_with_leading_zero_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration and reload of a device with a mac address with a leading zero.""" + mac_address = "01:02:03:04:05:06" + mac_address_unique_id = dr.format_mac(mac_address) + serial_number = "0" + + # Setup the config entry to be in a pre-migrated state + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + data={ + "host": "127.0.0.1", + "password": "password", + CONF_MAC: mac_address, + "serial_number": serial_number, + }, + ) + config_entry.add_to_hass(hass) + + # Create a device and entity with the old unique id format + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{serial_number}-1")}, + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{serial_number}-1-zone1", + suggested_object_id="zone1", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Setup the integration, which will migrate the unique ids + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity were migrated to the new format + migrated_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mac_address_unique_id}-1")} + ) + assert migrated_device_entry is not None + migrated_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert migrated_entity_entry is not None + assert migrated_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + # Reload the integration + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity still have the correct identifiers and were not duplicated + reloaded_device_entry = device_registry.async_get(migrated_device_entry.id) + assert reloaded_device_entry is not None + assert reloaded_device_entry.identifiers == {(DOMAIN, f"{mac_address_unique_id}-1")} + reloaded_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert reloaded_entity_entry is not None + assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) From dbdc666a924a55384babd75c54f4ce606365171d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 19:51:01 +0200 Subject: [PATCH 0410/1118] Use OptionsFlowWithReload in control4 (#149058) --- homeassistant/components/control4/__init__.py | 24 +++++++------------ .../components/control4/config_flow.py | 8 +++++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 3d84d6edd69..59216e4a863 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,16 +54,20 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): # noqa: RET503 +async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" - # Ruff doesn't understand this loop - the exception is always raised after the retries + exc = None for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: - _LOGGER.error("Error connecting to Control4 account API: %s", exception) - if i == API_RETRY_TIMES - 1: - raise ConfigEntryNotReady(exception) from exception + _LOGGER.error( + "Try: %d, Error connecting to Control4 account API: %s", + i + 1, + exception, + ) + exc = exception + raise ConfigEntryNotReady(exc) from exc async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: @@ -141,21 +145,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> ui_configuration=ui_configuration, ) - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener( - hass: HomeAssistant, config_entry: Control4ConfigEntry -) -> None: - """Update when config_entry options update.""" - _LOGGER.debug("Config entry was updated, rerunning setup") - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 3ca96ca4e52..9d5df61b513 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,7 +11,11 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -153,7 +157,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Control4.""" async def async_step_init( From d266b6f6abe256c0cb5b989cbfe7458e0e84cfec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 19 Jul 2025 20:08:20 +0200 Subject: [PATCH 0411/1118] Use OptionsFlowWithReload in AVM Fritz!Box Tools (#149085) --- homeassistant/components/fritz/__init__.py | 8 -------- homeassistant/components/fritz/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index faf82b4b516..94f4f8ba0d8 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo if FRITZ_DATA_KEY not in hass.data: hass.data[FRITZ_DATA_KEY] = FritzData() - entry.async_on_unload(entry.add_update_listener(update_listener)) - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -94,9 +92,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo hass.data.pop(FRITZ_DATA_KEY) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 2c22a35c4dd..270e9870c63 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -409,7 +413,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( From be644ca96e53ad2808588dd8838f8dde1ca2ac0c Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:39:22 +0100 Subject: [PATCH 0412/1118] Add type to coordinator for Squeezebox (#149087) --- homeassistant/components/squeezebox/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 8bfb952b680..9508420ec5f 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -30,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): +class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LMS Status custom coordinator.""" config_entry: SqueezeboxConfigEntry @@ -59,13 +59,13 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): else: _LOGGER.warning("Can't query server capabilities %s", self.lms.name) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from LMS status call. Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data: dict | None = await self.lms.async_prepared_status() + data: dict[str, Any] | None = await self.lms.async_prepared_status() if not data: raise UpdateFailed( From 51d38f8f05398a1e96a8d1d6ee45a01be88e18c1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:06:14 +0200 Subject: [PATCH 0413/1118] Use OptionsFlowWithReload in emoncms (#149094) --- homeassistant/components/emoncms/__init__.py | 6 ------ homeassistant/components/emoncms/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 012abcc8c9a..1c081dc86e6 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -69,16 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index b14903a78f9..375077a83d4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback @@ -221,7 +221,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EmoncmsOptionsFlow(OptionsFlow): +class EmoncmsOptionsFlow(OptionsFlowWithReload): """Emoncms Options flow handler.""" def __init__(self, config_entry: ConfigEntry) -> None: From e885ae1b15c7052948b2b11989713664cd5296e8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:07:23 +0200 Subject: [PATCH 0414/1118] Use OptionsFlowWithReload in holiday (#149090) --- homeassistant/components/holiday/__init__.py | 6 ------ homeassistant/components/holiday/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index b364f2c67a4..f0c340785cf 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -34,16 +34,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 538d9971109..e9f16a9e4c5 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY from homeassistant.core import callback @@ -227,7 +227,7 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HolidayOptionsFlowHandler(OptionsFlow): +class HolidayOptionsFlowHandler(OptionsFlowWithReload): """Handle Holiday options.""" async def async_step_init( From afbb0ee2f4e8dd41e89c3ef0af43c2fa16408c28 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:07:55 +0200 Subject: [PATCH 0415/1118] Use OptionsFlowWithReload in github (#149089) --- homeassistant/components/github/__init__.py | 6 ------ homeassistant/components/github/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index dea2acf4f1b..df50039b03f 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo async_cleanup_device_registry(hass=hass, entry=entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -87,8 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 17338119b9f..a2a7e56830f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback @@ -214,7 +214,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for GitHub.""" async def async_step_init( From 96766fc62a9887a400dac20c92a95b127a47d68d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:08:37 +0200 Subject: [PATCH 0416/1118] Use OptionsFlowWithReload in Synology DSM (#149086) --- homeassistant/components/synology_dsm/__init__.py | 8 -------- homeassistant/components/synology_dsm/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index e568ce5a6d1..7146d42136e 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -136,7 +136,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) coordinator_switches=coordinator_switches, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: @@ -172,13 +171,6 @@ async def async_unload_entry( return unload_ok -async def _async_update_listener( - hass: HomeAssistant, entry: SynologyDSMConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index f0da6f8fe47..6e3469970d1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DISKS, @@ -441,7 +441,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return None -class SynologyDSMOptionsFlowHandler(OptionsFlow): +class SynologyDSMOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" config_entry: SynologyDSMConfigEntry From d35dca377fd03e3661d5387a7d028fa44df9cb8d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:11:15 +0200 Subject: [PATCH 0417/1118] Use OptionsFlowWithReload in purpleair (#149095) --- homeassistant/components/purpleair/__init__.py | 7 ------- homeassistant/components/purpleair/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 78986b34351..0b7acdb1eb0 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -20,16 +20,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: - """Reload config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3ca7870b3cb..29139872913 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -312,7 +312,7 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_by_coordinates() -class PurpleAirOptionsFlowHandler(OptionsFlow): +class PurpleAirOptionsFlowHandler(OptionsFlowWithReload): """Handle a PurpleAir options flow.""" def __init__(self) -> None: From d796ab8fe70b4d921c5033bc8a7e40846c8c7609 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:12:17 +0200 Subject: [PATCH 0418/1118] Use OptionsFlowWithReload in kitchen_sink (#149091) --- homeassistant/components/kitchen_sink/__init__.py | 7 ------- homeassistant/components/kitchen_sink/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2f876ca855d..8b81cd49279 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -101,19 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Start a reauth flow entry.async_start_reauth(hass) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" # Notify backup listeners diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index aa722d27944..059fd11999f 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.core import callback @@ -65,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( From 507f29a2098c9b2b10afe0e4eb85d983f2ccc0fd Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:12:49 +0200 Subject: [PATCH 0419/1118] Bump homematicip to 2.2.0 (#149038) --- homeassistant/components/homematicip_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/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 036ffa286a3..14b5ac39310 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.7"] + "requirements": ["homematicip==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1529fdd306f..f54d00e6fea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac1be38ee4d..e1b0d36db2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 1a6bfc03106d18b1082be4d8167b231d4617abd8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 10:17:09 +0200 Subject: [PATCH 0420/1118] Use OptionsFlowWithReload in knx (#149097) --- homeassistant/components/knx/__init__.py | 7 ------- homeassistant/components/knx/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6fa4c8146ba..ead846735c9 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[KNX_MODULE_KEY] = knx_module - entry.async_on_unload(entry.add_update_listener(async_update_entry)) - if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( @@ -174,11 +172,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update a given config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 796c4c60201..7772f366493 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -899,7 +899,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) -class KNXOptionsFlow(OptionsFlow): +class KNXOptionsFlow(OptionsFlowWithReload): """Handle KNX options.""" def __init__(self, config_entry: ConfigEntry) -> None: From ead99c549fabacdc5dbf4649efebc7f297ef2839 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 11:12:51 +0200 Subject: [PATCH 0421/1118] Use OptionsFlowWithReload in denonavr (#149109) --- homeassistant/components/denonavr/__init__.py | 9 --------- homeassistant/components/denonavr/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index da2b601317a..8cead5f4992 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -53,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -100,10 +98,3 @@ async def async_unload_entry( _LOGGER.debug("Removing zone3 from DenonAvr") return unload_ok - - -async def update_listener( - hass: HomeAssistant, config_entry: DenonavrConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 930d0e009ac..204471a13b4 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,11 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client @@ -51,7 +55,7 @@ DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From b262a5c9b63ec84962780b89e0ad69be72946a5a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:05:24 +0200 Subject: [PATCH 0422/1118] Use OptionsFlowWithReload in lastfm (#149113) --- homeassistant/components/lastfm/__init__.py | 6 ------ homeassistant/components/lastfm/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index b5a4612429e..90bee0cf4e7 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -16,7 +16,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -24,8 +23,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Unload lastfm config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: LastFMConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 422c50a5fb9..47c5b0e217e 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -155,7 +159,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class LastFmOptionsFlowHandler(OptionsFlow): +class LastFmOptionsFlowHandler(OptionsFlowWithReload): """LastFm Options flow handler.""" config_entry: LastFMConfigEntry From 5d653d46c3b303a793d353921013569e056e617a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:30:22 +0200 Subject: [PATCH 0423/1118] Remove not used config entry update listener from nut (#149096) --- homeassistant/components/nut/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 2f2c6badc4c..e3460f5a687 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -116,7 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: _LOGGER.debug("NUT Sensors Available: %s", status if status else None) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id @@ -199,11 +198,6 @@ async def async_remove_config_entry_device( ) -async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def _manufacturer_from_status(status: dict[str, str]) -> str | None: """Find the best manufacturer value from the status.""" return ( From 0c858de1af8e6698172dbeb8726a6828925a3206 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:31:18 +0200 Subject: [PATCH 0424/1118] Use OptionsFlowWithReload in lamarzocco (#149119) --- homeassistant/components/lamarzocco/__init__.py | 7 ------- homeassistant/components/lamarzocco/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 2d68b3be345..92184b4ac51 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -154,13 +154,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, entry: LaMarzoccoConfigEntry - ) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index e352e337d0b..fb968a0b4af 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ADDRESS, @@ -363,7 +363,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlow): +class LmOptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init( From 0d42b244675b2f94404155d5ff76f805923ee2aa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:05:39 +0200 Subject: [PATCH 0425/1118] Use OptionsFlowWithReload in jewish_calendar (#149121) --- homeassistant/components/jewish_calendar/__init__.py | 7 ------- homeassistant/components/jewish_calendar/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index ec73d960140..8e01b6b6ae0 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -79,13 +79,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, config_entry: JewishCalendarConfigEntry - ) -> None: - # Trigger update of states for all platforms - await hass.config_entries.async_reload(config_entry.entry_id) - - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index e896bc90c9e..f52e14537b3 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -9,7 +9,11 @@ import zoneinfo from hdate.translator import Language import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, @@ -124,7 +128,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlow): +class JewishCalendarOptionsFlowHandler(OptionsFlowWithReload): """Handle Jewish Calendar options.""" async def async_step_init( From 1b8f3348b0431d6bd835c80ebb0ea0fc46ec51c2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:06:59 +0200 Subject: [PATCH 0426/1118] Use OptionsFlowWithReload in roborock (#149118) --- homeassistant/components/roborock/__init__.py | 8 -------- homeassistant/components/roborock/config_flow.py | 13 ++++--------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 6697779adf6..bc10ab7309c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -43,8 +43,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) - user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient( entry.data[CONF_USERNAME], @@ -336,12 +334,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: - """Handle options update.""" - # Reload entry to update data - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: """Handle removal of an entry.""" await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 62943e0dcc9..6a35bf79233 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -124,14 +124,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() - self.hass.config_entries.async_update_entry( - reauth_entry, - data={ - **reauth_entry.data, - CONF_USER_DATA: user_data.as_dict(), - }, + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()} ) - return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured(error="already_configured_account") return self._create_entry(self._client, self._username, user_data) @@ -202,7 +197,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): return RoborockOptionsFlowHandler(config_entry) -class RoborockOptionsFlowHandler(OptionsFlow): +class RoborockOptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for Roborock.""" def __init__(self, config_entry: RoborockConfigEntry) -> None: From b31e17f1f9649e38955263137f20d297a5393021 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:07:46 +0200 Subject: [PATCH 0427/1118] Use OptionsFlowWithReload in met (#149115) --- homeassistant/components/met/__init__.py | 6 ------ homeassistant/components/met/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 17fc411bf20..d5f80d442a4 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry( config_entry.runtime_data = coordinator - config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) config_entry.async_on_unload(coordinator.untrack_home) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -64,11 +63,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_update_entry(hass: HomeAssistant, config_entry: MetWeatherConfigEntry): - """Reload Met component when options changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def cleanup_old_device(hass: HomeAssistant) -> None: """Cleanup device without proper device identifier.""" device_reg = dr.async_get(hass) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index e5db80b2997..54d528a7406 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ELEVATION, @@ -147,7 +147,7 @@ class MetConfigFlowHandler(ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithReload): """Options flow for Met component.""" async def async_step_init( From 302b6f03baefd1caa9f5c9821b83d251b8e4894b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:08:42 +0200 Subject: [PATCH 0428/1118] Use OptionsFlowWithReload in speedtest (#149111) --- homeassistant/components/speedtestdotnet/__init__.py | 8 -------- homeassistant/components/speedtestdotnet/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4f439013c6..5f66ba380fe 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry( async_at_started(hass, _async_finish_startup) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True @@ -52,10 +51,3 @@ async def async_unload_entry( ) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: SpeedTestConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 4fbca5e0d29..4bae503f85e 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,7 +6,11 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from .const import ( @@ -45,7 +49,7 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) -class SpeedTestOptionsFlowHandler(OptionsFlow): +class SpeedTestOptionsFlowHandler(OptionsFlowWithReload): """Handle SpeedTest options.""" def __init__(self) -> None: From 43dc73c2e1b272ad364aa6085fe6e10168493e83 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:09:07 +0200 Subject: [PATCH 0429/1118] Use OptionsFlowWithReload in forecast_solar (#149112) --- homeassistant/components/forecast_solar/__init__.py | 9 --------- homeassistant/components/forecast_solar/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 171341f7226..7b534b80500 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -47,8 +47,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - return True @@ -57,10 +55,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: ForecastSolarConfigEntry -) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 9a64ce6e1fb..031764a0d0a 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback @@ -88,7 +88,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): ) -class ForecastSolarOptionFlowHandler(OptionsFlow): +class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( From e3bdd12dadce4a95f14bee9b13610f03afe9c957 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Sun, 20 Jul 2025 14:13:24 +0200 Subject: [PATCH 0430/1118] Add Bauknecht virtual integration (#146801) --- homeassistant/components/bauknecht/__init__.py | 1 + homeassistant/components/bauknecht/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/bauknecht/__init__.py create mode 100644 homeassistant/components/bauknecht/manifest.json diff --git a/homeassistant/components/bauknecht/__init__.py b/homeassistant/components/bauknecht/__init__.py new file mode 100644 index 00000000000..1e93f1ab0c2 --- /dev/null +++ b/homeassistant/components/bauknecht/__init__.py @@ -0,0 +1 @@ +"""Bauknecht virtual integration.""" diff --git a/homeassistant/components/bauknecht/manifest.json b/homeassistant/components/bauknecht/manifest.json new file mode 100644 index 00000000000..b875d7fbc31 --- /dev/null +++ b/homeassistant/components/bauknecht/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bauknecht", + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 480a88e1ae4..8782d5c84b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -665,6 +665,11 @@ "config_flow": true, "iot_class": "local_push" }, + "bauknecht": { + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "bbox": { "name": "Bbox", "integration_type": "hub", From 72d5578128cf69bcbf28e2bf424f80aadbea5841 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Jul 2025 14:29:18 +0200 Subject: [PATCH 0431/1118] Fix typo in `#device-discovery-payload` anchor link of `mqtt` (#149116) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 1315463ebcf..8cb66270331 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -678,7 +678,7 @@ }, "data_description": { "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", - "discovery_payload": "The JSON [discovery payload]({url}#discovery-discovery-payload) that contains information about the MQTT device." + "discovery_payload": "The JSON [discovery payload]({url}#device-discovery-payload) that contains information about the MQTT device." } } }, From 216e89dc5e149e6f71d487eb41748ee3624d2345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 20 Jul 2025 13:50:17 +0100 Subject: [PATCH 0432/1118] Add battery charging state icons to Reolink (#149125) --- homeassistant/components/reolink/icons.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index cf3079e51e8..875af48e47c 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -402,7 +402,12 @@ "default": "mdi:thermometer" }, "battery_state": { - "default": "mdi:battery-charging" + "default": "mdi:battery-unknown", + "state": { + "discharging": "mdi:battery-minus-variant", + "charging": "mdi:battery-charging", + "chargecomplete": "mdi:battery-check" + } }, "day_night_state": { "default": "mdi:theme-light-dark" From ca48b9e375c44b38d2b973adcfff0682a1143980 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 20 Jul 2025 20:41:49 +0200 Subject: [PATCH 0433/1118] Bump uiprotect to version 7.15.1 (#149124) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8243a55d779..8d77a59955f 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.14.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.15.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index f54d00e6fea..dd50d29660a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.2 +uiprotect==7.15.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1b0d36db2b..9d8839f1b0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.2 +uiprotect==7.15.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 44fec53bacb8b479a91b15d1affce0c51b8f7997 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Sun, 20 Jul 2025 22:50:53 +0200 Subject: [PATCH 0434/1118] Add binary_sensor for door status in Huum (#149135) --- .../components/huum/binary_sensor.py | 42 ++++++++++++++++ homeassistant/components/huum/const.py | 2 +- .../huum/snapshots/test_binary_sensor.ambr | 50 +++++++++++++++++++ tests/components/huum/test_binary_sensor.py | 29 +++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huum/binary_sensor.py create mode 100644 tests/components/huum/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/huum/test_binary_sensor.py diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py new file mode 100644 index 00000000000..a8e094dda94 --- /dev/null +++ b/homeassistant/components/huum/binary_sensor.py @@ -0,0 +1,42 @@ +"""Sensor for door state.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up door sensor.""" + async_add_entities( + [HuumDoorSensor(config_entry.runtime_data)], + ) + + +class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor.""" + + _attr_name = "Door" + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the BinarySensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_door" + + @property + def is_on(self) -> bool | None: + """Return the current value.""" + return not self.coordinator.data.door_closed diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 69dea45b218..6691a2ad8b3 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] diff --git a/tests/components/huum/snapshots/test_binary_sensor.ambr b/tests/components/huum/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3490ff594b6 --- /dev/null +++ b/tests/components/huum/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.huum_sauna_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.huum_sauna_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': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.huum_sauna_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Huum sauna Door', + }), + 'context': , + 'entity_id': 'binary_sensor.huum_sauna_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/test_binary_sensor.py b/tests/components/huum/test_binary_sensor.py new file mode 100644 index 00000000000..5ea2ae69a11 --- /dev/null +++ b/tests/components/huum/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "binary_sensor.huum_sauna_door" + + +async def test_binary_sensor( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.BINARY_SENSOR] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From b8d45fba246aad36a9628657b012c74ed1710750 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jul 2025 10:53:09 -1000 Subject: [PATCH 0435/1118] Bump aioesphomeapi to 37.0.2 (#149143) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index bb1f2d28457..e83ab16064c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.0.1", + "aioesphomeapi==37.0.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index dd50d29660a..8aaa9817775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.1 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d8839f1b0d..caa83b80ddb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.1 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 From e3577de9d888709867c7c0330c4f3bd6cafbd060 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:17:43 +0200 Subject: [PATCH 0436/1118] Use OptionsFlowWithReload in onkyo (#149093) --- homeassistant/components/onkyo/__init__.py | 6 ------ homeassistant/components/onkyo/config_flow.py | 6 +++--- tests/components/onkyo/test_init.py | 20 ------------------- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index 67ed4162778..d0f93012eb7 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -47,7 +47,6 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bool: """Set up the Onkyo config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) host = entry.data[CONF_HOST] @@ -82,8 +81,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo receiver.conn.close() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: OnkyoConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 85ff0de3251..2b8f9981e4a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -329,7 +329,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -357,7 +357,7 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ) -class OnkyoOptionsFlowHandler(OptionsFlow): +class OnkyoOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Onkyo.""" _data: dict[str, Any] diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 17086a3088e..4c6ddcca214 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -33,26 +33,6 @@ async def test_load_unload_entry( assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_entry( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test update options.""" - - with patch.object(hass.config_entries, "async_reload", return_value=True): - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) - - # Force option change - assert hass.config_entries.async_update_entry( - config_entry, options={"option": "new_value"} - ) - await hass.async_block_till_done() - - hass.config_entries.async_reload.assert_called_with(config_entry.entry_id) - - async def test_no_connection( hass: HomeAssistant, config_entry: MockConfigEntry, From 61ca0b6b86f149735a02aef77c3a49c54a351b59 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:18:00 +0200 Subject: [PATCH 0437/1118] Use OptionsFlowWithReload in vodafone_station (#149131) --- homeassistant/components/vodafone_station/__init__.py | 8 -------- homeassistant/components/vodafone_station/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 17b0fe6e501..0433199b54e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -25,8 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -39,9 +37,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> await coordinator.api.logout() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c330a93a1a8..13e30d38926 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -180,7 +184,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): ) -class VodafoneStationOptionsFlowHandler(OptionsFlow): +class VodafoneStationOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" async def async_step_init( From 77a954df9b69e798f8b4400e57e41504132a56b5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:44:39 +0200 Subject: [PATCH 0438/1118] Use OptionsFlowWithReload in reolink (#149132) --- homeassistant/components/reolink/__init__.py | 11 ---------- .../components/reolink/config_flow.py | 4 ++-- tests/components/reolink/test_init.py | 20 ------------------- 3 files changed, 2 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3260bff44b5..236e1707461 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -243,10 +243,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - return True @@ -295,13 +291,6 @@ async def register_callbacks( ) -async def entry_update_listener( - hass: HomeAssistant, config_entry: ReolinkConfigEntry -) -> None: - """Update the configuration of the host entity.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index eee8b04dfcc..2ac51792c3f 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -61,7 +61,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} API_STARTUP_TIME = 5 -class ReolinkOptionsFlowHandler(OptionsFlow): +class ReolinkOptionsFlowHandler(OptionsFlowWithReload): """Handle Reolink options.""" async def async_step_init( diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e439d3dff93..10eefccace9 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -180,26 +180,6 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues -async def test_entry_reloading( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test the entry is reloaded correctly when settings change.""" - reolink_host.is_nvr = False - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 0 - assert config_entry.title == "test_reolink_name" - - hass.config_entries.async_update_entry(config_entry, title="New Name") - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 1 - assert config_entry.title == "New Name" - - @pytest.mark.parametrize( ("attr", "value", "expected_models"), [ From 0a9fbb215dba945eb206226b66852b99e1dbbf88 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 21 Jul 2025 02:22:32 +0200 Subject: [PATCH 0439/1118] Bump uiprotect to version 7.16.0 (#149146) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8d77a59955f..e5b017e0ab6 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.15.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.16.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 8aaa9817775..8b699e56316 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.15.1 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index caa83b80ddb..81a07abd45c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.15.1 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 27787e0679ce88aefafef1e6fe84351c4e0a43fb Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 01:25:45 -0400 Subject: [PATCH 0440/1118] Bump pyschlage to 2025.7.2 (#149148) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 893c30dfd41..c5b91cefd2e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.4.0"] + "requirements": ["pyschlage==2025.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b699e56316..b7e3fd074b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81a07abd45c..30ad1b2e5fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 From bd7cef92c7d21ed95b4aff6557e97c1a93b69fea Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Jul 2025 07:26:29 +0200 Subject: [PATCH 0441/1118] Use OptionsFlowWithReload in Proximity (#149136) --- homeassistant/components/proximity/__init__.py | 8 -------- homeassistant/components/proximity/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 2338464558d..4dc87554055 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -43,17 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: ProximityConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 5818ec2979b..f60dcfae7b5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback @@ -87,7 +87,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> ProximityOptionsFlow: """Get the options flow for this handler.""" return ProximityOptionsFlow() @@ -118,7 +118,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): ) -class ProximityOptionsFlow(OptionsFlow): +class ProximityOptionsFlow(OptionsFlowWithReload): """Handle a option flow.""" def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: From eca80a1645ca0b5d56f9820ccf53bb7abf701962 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Jul 2025 07:27:02 +0200 Subject: [PATCH 0442/1118] Use OptionsFlowWithReload in Feedreader (#149134) --- homeassistant/components/feedreader/__init__.py | 9 --------- homeassistant/components/feedreader/config_flow.py | 9 ++++----- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 57c58d3a2b1..9acec01ee6d 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -32,8 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -46,10 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) if len(entries) == 1: hass.data.pop(MY_KEY) return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: FeedReaderConfigEntry -) -> None: - """Handle reconfiguration.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 3d0fec1a6f5..37c627f21ba 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -44,7 +44,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> FeedReaderOptionsFlowHandler: """Get the options flow for this handler.""" return FeedReaderOptionsFlowHandler() @@ -119,11 +119,10 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "url_error"}, ) - self.hass.config_entries.async_update_entry(reconfigure_entry, data=user_input) - return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class FeedReaderOptionsFlowHandler(OptionsFlow): +class FeedReaderOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( From bc9ad5eac64372f30fe0a1a7e1576958eefc2223 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 21 Jul 2025 08:15:32 +0200 Subject: [PATCH 0443/1118] Add device class to gardena (#149144) --- homeassistant/components/gardena_bluetooth/number.py | 6 ++++++ homeassistant/components/gardena_bluetooth/valve.py | 7 ++++++- .../gardena_bluetooth/snapshots/test_number.ambr | 11 +++++++++++ .../gardena_bluetooth/snapshots/test_valve.ambr | 2 ++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 41b4f1e79ba..342061c18d1 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -13,6 +13,7 @@ from gardena_bluetooth.parse import ( ) from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -54,6 +55,7 @@ DESCRIPTIONS = ( native_step=60, entity_category=EntityCategory.CONFIG, char=Valve.manual_watering_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.uuid, @@ -64,6 +66,7 @@ DESCRIPTIONS = ( native_step=60.0, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.remaining_open_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.uuid, @@ -75,6 +78,7 @@ DESCRIPTIONS = ( native_step=6 * 60.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.rain_pause, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.seasonal_adjust.uuid, @@ -86,6 +90,7 @@ DESCRIPTIONS = ( native_step=1.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.seasonal_adjust, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Sensor.threshold.uuid, @@ -153,6 +158,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit _attr_native_min_value = 0.0 _attr_native_max_value = 24 * 60 _attr_native_step = 1.0 + _attr_device_class = NumberDeviceClass.DURATION def __init__( self, diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 4138c7c4472..247a85f93f1 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -6,7 +6,11 @@ from typing import Any from gardena_bluetooth.const import Valve -from homeassistant.components.valve import ValveEntity, ValveEntityFeature +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_is_closed: bool | None = None _attr_reports_position = False _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER characteristics = { Valve.state.uuid, diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index c89ead450d2..4bc1e7e8dcb 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -2,6 +2,7 @@ # name: test_bluetooth_error_unavailable StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -20,6 +21,7 @@ # name: test_bluetooth_error_unavailable.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -38,6 +40,7 @@ # name: test_bluetooth_error_unavailable.2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -56,6 +59,7 @@ # name: test_bluetooth_error_unavailable.3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -110,6 +114,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -128,6 +133,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -146,6 +152,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -164,6 +171,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -182,6 +190,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Open for', 'max': 1440, 'min': 0.0, @@ -200,6 +209,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -218,6 +228,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr index c030332e75b..4a0da40a143 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -2,6 +2,7 @@ # name: test_setup StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), @@ -16,6 +17,7 @@ # name: test_setup.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), From 00c4b097734d3b2660bf831f8e1452c3a12a4caf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:15:51 +0200 Subject: [PATCH 0444/1118] Use OptionsFlowWithReload in motioneye (#149130) --- homeassistant/components/motioneye/__init__.py | 6 ------ homeassistant/components/motioneye/config_flow.py | 4 ++-- tests/components/motioneye/test_config_flow.py | 4 ++-- tests/components/motioneye/test_web_hooks.py | 2 +- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3e4ad53d200..fec176847da 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -277,11 +277,6 @@ def _add_camera( ) -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up motionEye from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -382,7 +377,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_add_listener(_async_process_motioneye_cameras) ) await coordinator.async_refresh() - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 80a6449a22d..7704fb68412 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import callback @@ -186,7 +186,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): return MotionEyeOptionsFlow() -class MotionEyeOptionsFlow(OptionsFlow): +class MotionEyeOptionsFlow(OptionsFlowWithReload): """motionEye options flow.""" async def async_step_init( diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 8d942e7a2a1..f3c4820ff90 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -532,7 +532,7 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert CONF_STREAM_URL_TEMPLATE not in result["data"] assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} @@ -551,4 +551,4 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index bc345c0b66f..4e9d5e926a8 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -116,7 +116,6 @@ async def test_setup_camera_with_wrong_webhook( ) assert not client.async_set_camera.called - # Update the options, which will trigger a reload with the new behavior. with patch( "homeassistant.components.motioneye.MotionEyeClient", return_value=client, @@ -124,6 +123,7 @@ async def test_setup_camera_with_wrong_webhook( hass.config_entries.async_update_entry( config_entry, options={CONF_WEBHOOK_SET_OVERWRITE: True} ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() device = device_registry.async_get_device( From 11dd2dc374983658f2ca183ef3af480ca8c1dd95 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:17:12 +0200 Subject: [PATCH 0445/1118] Use OptionsFlowWithReload in file (#149108) --- homeassistant/components/file/__init__.py | 6 ------ homeassistant/components/file/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 7bc206057c8..59a08715b8e 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups( entry, [Platform(entry.data[CONF_PLATFORM])] ) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -41,11 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate config entry.""" if config_entry.version > 2: diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 1c4fdbe5c84..9078a4d115e 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -131,7 +131,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_handle_step(Platform.SENSOR.value, user_input) -class FileOptionsFlowHandler(OptionsFlow): +class FileOptionsFlowHandler(OptionsFlowWithReload): """Handle File options.""" async def async_step_init( From c1e35cc9cfe8c74e830908eeea705c5099960b1d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:18:40 +0200 Subject: [PATCH 0446/1118] Use OptionsFlowWithReload in androidtv_remote (#149133) --- homeassistant/components/androidtv_remote/__init__.py | 11 ----------- .../components/androidtv_remote/config_flow.py | 10 +++++----- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index c8556b6da90..328ac863e46 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -68,7 +68,6 @@ async def async_setup_entry( entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) return True @@ -80,13 +79,3 @@ async def async_unload_entry( """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug( - "async_update_options: data: %s options: %s", entry.data, entry.options - ) - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 351cae61b1d..0a236c7c9ef 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -116,10 +116,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): pin = user_input["pin"] await self.api.async_finish_pairing(pin) if self.source == SOURCE_REAUTH: - await self.hass.config_entries.async_reload( - self._get_reauth_entry().entry_id + return self.async_update_reload_and_abort( + self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True ) - return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=self.name, data={ @@ -243,7 +243,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload): """Android TV Remote options flow.""" def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None: From 6eab118a2d5e9f64d1ded17aa45de9fe95eb7b86 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Jul 2025 08:26:20 +0200 Subject: [PATCH 0447/1118] Bump airgradient to platinum (#149014) --- .../components/airgradient/manifest.json | 1 + .../components/airgradient/quality_scale.yaml | 32 ++++++++----------- script/hassfest/quality_scale.py | 1 - 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index afaf2698ced..3011e0602c9 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 7a7f8d5ee1d..ec2e200b0a7 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -14,9 +14,9 @@ rules: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -34,7 +34,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options to configure - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -43,23 +43,19 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: DHCP is still possible - discovery: - status: todo - comment: DHCP is still possible - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index b5fd8c3ad7a..3008c6303ff 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1162,7 +1162,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "aftership", "agent_dvr", "airly", - "airgradient", "airnow", "airq", "airthings", From ff9fb6228b30afd03fb3ec1e183c66194b54b0d5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 11:14:02 +0200 Subject: [PATCH 0448/1118] Use OptionsFlowWithReload in onewire (#149164) --- homeassistant/components/onewire/__init__.py | 10 --------- .../components/onewire/config_flow.py | 8 +++++-- tests/components/onewire/test_init.py | 22 ------------------- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c77d87d91b9..396539d93e3 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -39,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b onewire_hub.schedule_scan_for_new_devices() - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - return True @@ -59,11 +57,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) - - -async def options_update_listener( - hass: HomeAssistant, entry: OneWireConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading OneWire integration") - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 2099d9aabb5..0f2a2b6c51c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -160,7 +164,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return OnewireOptionsFlowHandler(config_entry) -class OnewireOptionsFlowHandler(OptionsFlow): +class OnewireOptionsFlowHandler(OptionsFlowWithReload): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 0748481c40b..ace7afb5645 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,6 +1,5 @@ """Tests for 1-Wire config flow.""" -from copy import deepcopy from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -63,27 +62,6 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_options( - hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock -) -> None: - """Test update options triggers reload.""" - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 1 - - new_options = deepcopy(dict(config_entry.options)) - new_options["device_options"].clear() - hass.config_entries.async_update_entry(config_entry, options=new_options) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 2 - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_registry( hass: HomeAssistant, From c08aa744967f47dce94e140920400c2aa1263cfb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:27:37 +0200 Subject: [PATCH 0449/1118] Cleanup Tuya climate/cover tests (#149157) --- tests/components/tuya/__init__.py | 2 +- tests/components/tuya/test_climate.py | 26 ++++++++++++++++---------- tests/components/tuya/test_cover.py | 7 ++++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1ce7e6c47dd..d9016d18def 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -15,8 +15,8 @@ from tests.common import MockConfigEntry DEVICE_MOCKS = { "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 - Platform.SELECT, Platform.COVER, + Platform.SELECT, ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index d564c027cd1..9c0e3c31a26 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -8,6 +8,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -69,16 +73,17 @@ async def test_fan_mode_windspeed( mock_device: CustomerDevice, ) -> None: """Test fan mode with windspeed.""" + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes["fan_mode"] == 1 await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, ) @@ -104,17 +109,18 @@ async def test_fan_mode_no_valid_code( mock_device.status_range.pop("windspeed", None) mock_device.status.pop("windspeed", None) + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes.get("fan_mode") is None with pytest.raises(ServiceNotSupported): await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, blocking=True, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 3b190e46827..29a6d65978f 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -83,8 +83,9 @@ async def test_percent_state_on_cover( # 100 is closed and 0 is open for Tuya covers mock_device.status["percent_state"] = 100 - percent_state + entity_id = "cover.kitchen_blinds_curtain" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - cover_state = hass.states.get("cover.kitchen_blinds_curtain") - assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" - assert cover_state.attributes["current_position"] == percent_state + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes["current_position"] == percent_state From 8c964e64db2dcae8af227ffbea656ed1b013290b Mon Sep 17 00:00:00 2001 From: Elmo-S <71403256+Elmo-S@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:39:46 +0300 Subject: [PATCH 0450/1118] Add support for UV index attribute in template weather entity (#149015) --- homeassistant/components/template/weather.py | 26 +++++++++++++++++++ .../template/snapshots/test_weather.ambr | 1 + tests/components/template/test_weather.py | 8 ++++++ 3 files changed, 35 insertions(+) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 15c6fb4db9e..7f79adc2201 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -90,6 +90,7 @@ CONF_PRESSURE_TEMPLATE = "pressure_template" CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" +CONF_UV_INDEX_TEMPLATE = "uv_index_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" @@ -122,6 +123,7 @@ WEATHER_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UV_INDEX_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, @@ -201,6 +203,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) self._wind_bearing_template = config.get(CONF_WIND_BEARING_TEMPLATE) self._ozone_template = config.get(CONF_OZONE_TEMPLATE) + self._uv_index_template = config.get(CONF_UV_INDEX_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) @@ -228,6 +231,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed = None self._wind_bearing = None self._ozone = None + self._uv_index = None self._visibility = None self._wind_gust_speed = None self._cloud_coverage = None @@ -275,6 +279,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Return the ozone level.""" return self._ozone + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._uv_index + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -369,6 +378,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): "_ozone", self._ozone_template, ) + if self._uv_index_template: + self.add_template_attribute( + "_uv_index", + self._uv_index_template, + ) if self._visibility_template: self.add_template_attribute( "_visibility", @@ -480,6 +494,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone: float | None last_pressure: float | None last_temperature: float | None + last_uv_index: float | None last_visibility: float | None last_wind_bearing: float | str | None last_wind_gust_speed: float | None @@ -501,6 +516,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone=restored["last_ozone"], last_pressure=restored["last_pressure"], last_temperature=restored["last_temperature"], + last_uv_index=restored["last_uv_index"], last_visibility=restored["last_visibility"], last_wind_bearing=restored["last_wind_bearing"], last_wind_gust_speed=restored["last_wind_gust_speed"], @@ -553,6 +569,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): CONF_FORECAST_TWICE_DAILY_TEMPLATE, CONF_OZONE_TEMPLATE, CONF_PRESSURE_TEMPLATE, + CONF_UV_INDEX_TEMPLATE, CONF_VISIBILITY_TEMPLATE, CONF_WIND_BEARING_TEMPLATE, CONF_WIND_GUST_SPEED_TEMPLATE, @@ -583,6 +600,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_UV_INDEX_TEMPLATE] = weather_data.last_uv_index self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing self._rendered[CONF_WIND_GUST_SPEED_TEMPLATE] = ( @@ -630,6 +648,13 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered.get(CONF_OZONE_TEMPLATE), ) + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_UV_INDEX_TEMPLATE) + ) + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -703,6 +728,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_uv_index=self._rendered.get(CONF_UV_INDEX_TEMPLATE), last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index bdda5b44e94..215a10a4f40 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -46,6 +46,7 @@ 'last_ozone': None, 'last_pressure': None, 'last_temperature': '15.0', + 'last_uv_index': None, 'last_visibility': None, 'last_wind_bearing': None, 'last_wind_gust_speed': None, diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 443b0aa6e77..6e2a2ab2f6b 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -608,6 +609,7 @@ SAVED_EXTRA_DATA = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -623,6 +625,7 @@ SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -790,6 +793,7 @@ async def test_trigger_action(hass: HomeAssistant) -> None: "wind_speed_template": "{{ my_variable + 1 }}", "wind_bearing_template": "{{ my_variable + 1 }}", "ozone_template": "{{ my_variable + 1 }}", + "uv_index_template": "{{ my_variable + 1 }}", "visibility_template": "{{ my_variable + 1 }}", "pressure_template": "{{ my_variable + 1 }}", "wind_gust_speed_template": "{{ my_variable + 1 }}", @@ -864,6 +868,7 @@ async def test_trigger_weather_services( assert state.attributes["wind_speed"] == 3.0 assert state.attributes["wind_bearing"] == 3.0 assert state.attributes["ozone"] == 3.0 + assert state.attributes["uv_index"] == 3.0 assert state.attributes["visibility"] == 3.0 assert state.attributes["pressure"] == 3.0 assert state.attributes["wind_gust_speed"] == 3.0 @@ -962,6 +967,7 @@ SAVED_EXTRA_DATA_MISSING_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -1041,6 +1047,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: "wind_speed_template": "{{ states('sensor.windspeed') }}", "wind_bearing_template": "{{ states('sensor.windbearing') }}", "ozone_template": "{{ states('sensor.ozone') }}", + "uv_index_template": "{{ states('sensor.uv_index') }}", "visibility_template": "{{ states('sensor.visibility') }}", "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", @@ -1063,6 +1070,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.uv_index", ATTR_WEATHER_UV_INDEX, 3.7), ("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), From 0dba32dbcd1cc855d48e671530e4ab62daecf80e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:11 +0200 Subject: [PATCH 0451/1118] Use OptionsFlowWithReload in keenetic_ndms2 (#149173) --- homeassistant/components/keenetic_ndms2/__init__.py | 7 ------- homeassistant/components/keenetic_ndms2/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 7986158ab50..358f9600845 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -33,8 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> router = KeeneticRouter(hass, entry) await router.async_setup() - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,11 +85,6 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index c6095968c07..cec4796176e 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -153,7 +153,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() -class KeeneticOptionsFlowHandler(OptionsFlow): +class KeeneticOptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" config_entry: KeeneticConfigEntry From c22f65bd87055dec5c9c2b845032eb4b93d70a90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:24 +0200 Subject: [PATCH 0452/1118] Use OptionsFlowWithReload in isy994 (#149174) --- homeassistant/components/isy994/__init__.py | 6 ------ homeassistant/components/isy994/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 5d4603cafc0..68ca63b6bb5 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -171,7 +171,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: _LOGGER.debug("ISY Starting Event Stream and automatic updates") isy.websocket.start() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update) ) @@ -179,11 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 2acebee8599..4f0217fd0c6 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -143,7 +143,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: IsyConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -316,7 +316,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for ISY/IoX.""" async def async_step_init( From 94d077ea4150f65b07337a5ce8de09479c0892a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:38 +0200 Subject: [PATCH 0453/1118] Use OptionsFlowWithReload in honeywell (#149162) --- homeassistant/components/honeywell/__init__.py | 9 --------- homeassistant/components/honeywell/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 6c4c7091840..d270ffec72f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -83,18 +83,9 @@ async def async_setup_entry( config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - return True -async def update_listener( - hass: HomeAssistant, config_entry: HoneywellConfigEntry -) -> None: - """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> bool: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 15199cdda24..c18bb0296aa 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -136,7 +136,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): return HoneywellOptionsFlowHandler() -class HoneywellOptionsFlowHandler(OptionsFlow): +class HoneywellOptionsFlowHandler(OptionsFlowWithReload): """Config flow options for Honeywell.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: From bf1a660dcbb91b80322a03bbf23320f993a43bed Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:02:50 +0200 Subject: [PATCH 0454/1118] Bump Lokalise docker image to v2.6.14 (#149031) --- script/translations/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 9ff8aeb2d70..18aa27b3e74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.8" +CLI_2_DOCKER_IMAGE = "v2.6.14" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") From 1fba61973dbee174ea0e777d00bf78d907d5ab20 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:03:53 +0200 Subject: [PATCH 0455/1118] Update pytest-asyncio to 1.1.0 (#149177) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b758a7b517a..fa29e7053e0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -19,7 +19,7 @@ pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.0.0 +pytest-asyncio==1.1.0 pytest-aiohttp==1.1.0 pytest-cov==6.2.1 pytest-freezer==0.4.9 From 67c68dedbad6bd5fd22c63a4a48ba350e90452c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:07:52 +0200 Subject: [PATCH 0456/1118] Make async_track_state_change/report_event listeners fire in order (#148766) --- homeassistant/helpers/event.py | 2 +- tests/helpers/test_event.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f2dfb7250f7..39cff22396a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -402,7 +402,7 @@ def _async_track_state_change_event( _KEYED_TRACK_STATE_REPORT = _KeyedEventTracker( key=_TRACK_STATE_REPORT_DATA, event_type=EVENT_STATE_REPORTED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index c875522b943..32cf3edf010 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4969,11 +4969,9 @@ async def test_async_track_state_report_change_event(hass: HomeAssistant) -> Non hass.states.async_set(entity_id, state) await hass.async_block_till_done() - # The out-of-order is a result of state change listeners scheduled with - # loop.call_soon, whereas state report listeners are called immediately. assert tracker_called == { - "light.bowl": ["on", "off", "on", "off"], - "light.top": ["on", "off", "on", "off"], + "light.bowl": ["on", "on", "off", "off"], + "light.top": ["on", "on", "off", "off"], } From 75a90ab568ffda4d3fd69684349210648b7b35d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:11:22 +0200 Subject: [PATCH 0457/1118] Bump actions/ai-inference from 1.1.0 to 1.2.3 (#149159) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index b01a0d68352..0facf6fdf77 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 264b8ab9854..b1ce58c4b41 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o-mini system-prompt: | From b59d8b57301ce4e40adf8d11fba5a0f8914b0222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:20:04 +0200 Subject: [PATCH 0458/1118] Improve statistics sensor tests (#149181) --- tests/components/statistics/test_sensor.py | 36 +++++++++++++--------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 1db4acf3ef8..e882909878a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -6,7 +6,7 @@ from asyncio import Event as AsyncioEvent from collections.abc import Sequence from datetime import datetime, timedelta import statistics -from threading import Event +from threading import Event as ThreadingEvent from typing import Any from unittest.mock import patch @@ -42,8 +42,9 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1741,7 +1742,7 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) # some synchronisation is needed to prevent that loading from the database finishes too soon # we want this to take long enough to be able to try to add a value BEFORE loading is done state_changes_during_period_called_evt = AsyncioEvent() - state_changes_during_period_stall_evt = Event() + state_changes_during_period_stall_evt = ThreadingEvent() real_state_changes_during_period = history.state_changes_during_period def mock_state_changes_during_period(*args, **kwargs): @@ -1789,25 +1790,25 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) @pytest.mark.parametrize("force_update", [True, False]) @pytest.mark.parametrize( - ("values_attributes_and_times", "expected_state"), + ("values_attributes_and_times", "expected_states"), [ ( # Fires last reported events [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires state change events [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires last reported events [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ( # Fires state change events [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ], ) @@ -1815,12 +1816,21 @@ async def test_average_linear_unevenly_timed( hass: HomeAssistant, force_update: bool, values_attributes_and_times: list[tuple[float, dict[str, Any], float]], - expected_state: str, + expected_states: list[str], ) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event( + hass, "sensor.test_sensor_average_linear", _capture_event + ) current_time = dt_util.utcnow() @@ -1856,12 +1866,8 @@ async def test_average_linear_unevenly_timed( await hass.async_block_till_done() - state = hass.states.get("sensor.test_sensor_average_linear") - assert state is not None - assert state.state == expected_state, ( - "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == {expected_state}" - ) + await hass.async_block_till_done() + assert [event.data["new_state"].state for event in events] == expected_states async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None: From 05566e1621da6f38adfd764e37d277ccf36304ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:23:42 +0200 Subject: [PATCH 0459/1118] Update websockets pin (#149004) --- homeassistant/package_constraints.txt | 8 ++------ script/gen_requirements_all.py | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5f72d1c4c3..157ee1420fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -150,12 +150,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 005d97175a7..b45d48aeff4 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,12 +176,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python From be25a7bc70c916f171e7a024b143e6236156b4b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 13:24:15 +0200 Subject: [PATCH 0460/1118] Use OptionsFlowWithReload in ezviz (#149167) --- homeassistant/components/ezviz/__init__.py | 7 ------- homeassistant/components/ezviz/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index a93954b8a9b..65749871093 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,8 +94,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. @@ -120,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bo return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - - -async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 622f767443d..d90f04b403a 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,7 +17,11 @@ from pyezvizapi.exceptions import ( from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -386,7 +390,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EzvizOptionsFlowHandler(OptionsFlow): +class EzvizOptionsFlowHandler(OptionsFlowWithReload): """Handle EZVIZ client options.""" async def async_step_init( From d774de79db8a9c96a9fcf23f4bbf9b1d99b4c21c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:33:04 +0200 Subject: [PATCH 0461/1118] Update types packages (#149178) --- homeassistant/components/habitica/util.py | 9 +++++++-- requirements_test.txt | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 35e1577ae21..4f948b9b4d2 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from dateutil.rrule import ( DAILY, @@ -56,7 +56,12 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +FREQUENCY_MAP: dict[str, Literal[0, 1, 2, 3]] = { + "daily": DAILY, + "weekly": WEEKLY, + "monthly": MONTHLY, + "yearly": YEARLY, +} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} diff --git a/requirements_test.txt b/requirements_test.txt index fa29e7053e0..b0affc56113 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,17 +35,17 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250606 +types-aiofiles==24.1.0.20250708 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250411 +types-croniter==6.0.0.20250626 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250516 +types-protobuf==6.30.2.20250703 types-psutil==7.0.0.20250601 types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250516 +types-python-dateutil==2.9.0.20250708 types-python-slugify==8.0.2.20240310 types-pytz==2025.2.0.20250516 types-PyYAML==6.0.12.20250516 From bc0162cf858baf76fb0cf1ea59f2151960903c6d Mon Sep 17 00:00:00 2001 From: Luuk Dobber <1858881+luukdobber@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:45:57 +0200 Subject: [PATCH 0462/1118] Add select for heating circuit to Tado zones (#147902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Abílio Costa --- homeassistant/components/tado/__init__.py | 1 + homeassistant/components/tado/coordinator.py | 62 +- homeassistant/components/tado/select.py | 108 ++++ homeassistant/components/tado/strings.json | 8 + .../tado/fixtures/heating_circuits.json | 7 + .../tado/fixtures/zone_control.json | 80 +++ .../tado/snapshots/test_diagnostics.ambr | 561 ++++++++++++++++++ tests/components/tado/test_select.py | 91 +++ tests/components/tado/util.py | 12 + 9 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tado/select.py create mode 100644 tests/components/tado/fixtures/heating_circuits.json create mode 100644 tests/components/tado/fixtures/zone_control.json create mode 100644 tests/components/tado/test_select.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 0513d63b893..df33845437f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 09c6ec40208..79486ff998b 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -73,6 +73,8 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): "weather": {}, "geofence": {}, "zone": {}, + "zone_control": {}, + "heating_circuits": {}, } @property @@ -99,11 +101,14 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.home_name = tado_home["name"] devices = await self._async_update_devices() - zones = await self._async_update_zones() + zones, zone_controls = await self._async_update_zones() home = await self._async_update_home() + heating_circuits = await self._async_update_heating_circuits() self.data["device"] = devices self.data["zone"] = zones + self.data["zone_control"] = zone_controls + self.data["heating_circuits"] = heating_circuits self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] @@ -166,7 +171,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return mapped_devices - async def _async_update_zones(self) -> dict[int, dict]: + async def _async_update_zones(self) -> tuple[dict[int, dict], dict[int, dict]]: """Update the zone data from Tado.""" try: @@ -179,10 +184,12 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise UpdateFailed(f"Error updating Tado zones: {err}") from err mapped_zones: dict[int, dict] = {} + mapped_zone_controls: dict[int, dict] = {} for zone in zone_states: mapped_zones[int(zone)] = await self._update_zone(int(zone)) + mapped_zone_controls[int(zone)] = await self._update_zone_control(int(zone)) - return mapped_zones + return mapped_zones, mapped_zone_controls async def _update_zone(self, zone_id: int) -> dict[str, str]: """Update the internal data of a zone.""" @@ -199,6 +206,24 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) return data + async def _update_zone_control(self, zone_id: int) -> dict[str, Any]: + """Update the internal zone control data of a zone.""" + + _LOGGER.debug("Updating zone control for zone %s", zone_id) + try: + zone_control_data = await self.hass.async_add_executor_job( + self._tado.get_zone_control, zone_id + ) + except RequestException as err: + _LOGGER.error( + "Error updating Tado zone control for zone %s: %s", zone_id, err + ) + raise UpdateFailed( + f"Error updating Tado zone control for zone {zone_id}: {err}" + ) from err + + return zone_control_data + async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" @@ -217,6 +242,23 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return {"weather": weather, "geofence": geofence} + async def _async_update_heating_circuits(self) -> dict[str, dict]: + """Update the heating circuits data from Tado.""" + + try: + heating_circuits = await self.hass.async_add_executor_job( + self._tado.get_heating_circuits + ) + except RequestException as err: + _LOGGER.error("Error updating Tado heating circuits: %s", err) + raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err + + mapped_heating_circuits: dict[str, dict] = {} + for circuit in heating_circuits: + mapped_heating_circuits[circuit["driverShortSerialNo"]] = circuit + + return mapped_heating_circuits + async def get_capabilities(self, zone_id: int | str) -> dict: """Fetch the capabilities from Tado.""" @@ -364,6 +406,20 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + async def set_heating_circuit(self, zone_id: int, circuit_id: int | None) -> None: + """Set heating circuit for zone.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_heating_circuit, + zone_id, + circuit_id, + ) + except RequestException as exc: + raise HomeAssistantError( + f"Error setting Tado heating circuit: {exc}" + ) from exc + await self._update_zone_control(zone_id) + class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py new file mode 100644 index 00000000000..6db765128c2 --- /dev/null +++ b/homeassistant/components/tado/select.py @@ -0,0 +1,108 @@ +"""Module for Tado select entities.""" + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + +NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado select platform.""" + + tado = entry.runtime_data.coordinator + entities: list[SelectEntity] = [ + TadoHeatingCircuitSelectEntity(tado, zone["name"], zone["id"]) + for zone in tado.zones + if zone["type"] == "HEATING" + ] + + async_add_entities(entities, True) + + +class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity): + """Representation of a Tado heating circuit select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_icon = "mdi:water-boiler" + _attr_translation_key = "heating_circuit" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + ) -> None: + """Initialize the Tado heating circuit select entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit" + + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Update the selected heating circuit.""" + heating_circuit_id = ( + None + if option == NO_HEATING_CIRCUIT_OPTION + else self.coordinator.data["heating_circuits"].get(option, {}).get("number") + ) + await self.coordinator.set_heating_circuit(self.zone_id, heating_circuit_id) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + # Heating circuits list + heating_circuits = self.coordinator.data["heating_circuits"].values() + self._attr_options = [NO_HEATING_CIRCUIT_OPTION] + self._attr_options.extend(hc["driverShortSerialNo"] for hc in heating_circuits) + + # Current heating circuit + zone_control = self.coordinator.data["zone_control"].get(self.zone_id) + if zone_control and "heatingCircuit" in zone_control: + heating_circuit_number = zone_control["heatingCircuit"] + if heating_circuit_number is None: + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + # Find heating circuit by number + heating_circuit = next( + ( + hc + for hc in heating_circuits + if hc.get("number") == heating_circuit_number + ), + None, + ) + + if heating_circuit is None: + _LOGGER.error( + "Heating circuit with number %s not found for zone %s", + heating_circuit_number, + self.zone_name, + ) + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + self._attr_current_option = heating_circuit.get( + "driverShortSerialNo" + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 5d9c4237be8..ba1c9e95683 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -59,6 +59,14 @@ } } }, + "select": { + "heating_circuit": { + "name": "Heating circuit", + "state": { + "no_heating_circuit": "No circuit" + } + } + }, "switch": { "child_lock": { "name": "Child lock" diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json new file mode 100644 index 00000000000..723ceb76f95 --- /dev/null +++ b/tests/components/tado/fixtures/heating_circuits.json @@ -0,0 +1,7 @@ +[ + { + "number": 1, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890" + } +] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json new file mode 100644 index 00000000000..584fe9f3c92 --- /dev/null +++ b/tests/components/tado/fixtures/zone_control.json @@ -0,0 +1,80 @@ +{ + "type": "HEATING", + "earlyStartEnabled": false, + "heatingCircuit": 1, + "duties": { + "type": "HEATING", + "leader": { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + "drivers": [ + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ], + "uis": [ + { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ] + } +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index eefb818a88c..34d26c222fa 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -62,6 +62,13 @@ 'presence': 'HOME', 'presenceLocked': False, }), + 'heating_circuits': dict({ + 'RU1234567890': dict({ + 'driverSerialNo': 'RU1234567890', + 'driverShortSerialNo': 'RU1234567890', + 'number': 1, + }), + }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46, @@ -110,6 +117,560 @@ '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)", }), }), + 'zone_control': dict({ + '1': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '2': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '3': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '4': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '5': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '6': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + }), }), 'mobile_devices': dict({ 'mobile_device': dict({ diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py new file mode 100644 index 00000000000..e57b7510d1b --- /dev/null +++ b/tests/components/tado/test_select.py @@ -0,0 +1,91 @@ +"""The select tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +HEATING_CIRCUIT_SELECT_ENTITY = "select.baseboard_heater_heating_circuit" +NO_HEATING_CIRCUIT = "no_heating_circuit" +HEATING_CIRCUIT_OPTION = "RU1234567890" +ZONE_ID = 1 +HEATING_CIRCUIT_ID = 1 + + +async def test_heating_circuit_select(hass: HomeAssistant) -> None: + """Test creation of heating circuit select entity.""" + + await async_init_integration(hass) + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state is not None + assert state.state == HEATING_CIRCUIT_OPTION + assert NO_HEATING_CIRCUIT in state.attributes["options"] + assert HEATING_CIRCUIT_OPTION in state.attributes["options"] + + +@pytest.mark.parametrize( + ("option", "expected_circuit_id"), + [(HEATING_CIRCUIT_OPTION, HEATING_CIRCUIT_ID), (NO_HEATING_CIRCUIT, None)], +) +async def test_heating_circuit_select_action( + hass: HomeAssistant, option, expected_circuit_id +) -> None: + """Test selecting heating circuit option.""" + + await async_init_integration(hass) + + # Test selecting a specific heating circuit + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_heating_circuit" + ) as mock_set_zone_heating_circuit, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_control" + ) as mock_get_zone_control, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: HEATING_CIRCUIT_SELECT_ENTITY, + ATTR_OPTION: option, + }, + blocking=True, + ) + + mock_set_zone_heating_circuit.assert_called_with(ZONE_ID, expected_circuit_id) + assert mock_get_zone_control.called + + +@pytest.mark.usefixtures("caplog") +async def test_heating_circuit_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test when a heating circuit with a specific number is not found.""" + circuit_not_matching_zone_control = 999 + heating_circuits = [ + { + "number": circuit_not_matching_zone_control, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890", + } + ] + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_heating_circuits", + return_value=heating_circuits, + ): + await async_init_integration(hass) + + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state.state == NO_HEATING_CIRCUIT + + assert "Heating circuit with number 1 not found for zone" in caplog.text diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 8ee7209acb2..5ef0ab5dbf2 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,8 +20,10 @@ async def async_init_integration( me_fixture = "me.json" weather_fixture = "weather.json" home_fixture = "home.json" + home_heating_circuits_fixture = "heating_circuits.json" home_state_fixture = "home_state.json" zones_fixture = "zones.json" + zone_control_fixture = "zone_control.json" zone_states_fixture = "zone_states.json" # WR1 Device @@ -70,6 +72,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/", text=await async_load_fixture(hass, home_fixture, DOMAIN), ) + m.get( + "https://my.tado.com/api/v2/homes/1/heatingCircuits", + text=await async_load_fixture(hass, home_heating_circuits_fixture, DOMAIN), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), @@ -178,6 +184,12 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), ) + zone_ids = [1, 2, 3, 4, 5, 6] + for zone_id in zone_ids: + m.get( + f"https://my.tado.com/api/v2/homes/1/zones/{zone_id}/control", + text=await async_load_fixture(hass, zone_control_fixture, DOMAIN), + ) m.post( "https://login.tado.com/oauth2/token", text=await async_load_fixture(hass, token_fixture, DOMAIN), From 875219ccb551108feaed31f9725d54dde82665fe Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 21 Jul 2025 14:02:04 +0200 Subject: [PATCH 0463/1118] Adds support for hide_states options in state selector (#148959) --- homeassistant/helpers/selector.py | 6 ++++-- tests/helpers/test_selector.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9eaedc6f5ef..2429b4b23e8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1338,7 +1338,8 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" - entity_id: Required[str] + entity_id: str + hide_states: list[str] @SELECTORS.register("state") @@ -1349,7 +1350,8 @@ class StateSelector(Selector[StateSelectorConfig]): CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { - vol.Required("entity_id"): cv.entity_id, + vol.Optional("entity_id"): cv.entity_id, + vol.Optional("hide_states"): [str], # The attribute to filter on, is currently deliberately not # configurable/exposed. We are considering separating state # selectors into two types: one for state and one for attribute. diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 9e8f1b15311..50d9da501c5 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -565,6 +565,11 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ("on", "armed"), (None, True, 1), ), + ( + {"hide_states": ["unknown", "unavailable"]}, + (), + (), + ), ], ) def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None: From 2d86fa079e9d462f91453cf5e3008adf2bb26b4f Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Mon, 21 Jul 2025 08:14:33 -0400 Subject: [PATCH 0464/1118] SleepIQ add core climate for SleepNumber Climate 360 beds (#134718) --- homeassistant/components/sleepiq/const.py | 4 + homeassistant/components/sleepiq/number.py | 54 ++++++++++++- homeassistant/components/sleepiq/select.py | 62 ++++++++++++++- homeassistant/components/sleepiq/strings.json | 11 +++ tests/components/sleepiq/conftest.py | 17 ++++ tests/components/sleepiq/test_number.py | 39 ++++++++++ tests/components/sleepiq/test_select.py | 77 ++++++++++++++++++- 7 files changed, 261 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4243684cd52..7a9415bac20 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -4,6 +4,8 @@ DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" ACTUATOR = "actuator" +CORE_CLIMATE_TIMER = "core_climate_timer" +CORE_CLIMATE = "core_climate" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -15,6 +17,8 @@ FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", + CORE_CLIMATE_TIMER: "Core Climate Timer", + CORE_CLIMATE: "Core Climate", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 53d6c366e46..ffbcbe7a970 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -7,20 +7,28 @@ from dataclasses import dataclass from typing import Any, cast from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQSleeper, ) -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, + CORE_CLIMATE_TIMER, DOMAIN, ENTITY_TYPES, FIRMNESS, @@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" +async def _async_set_core_climate_time( + core_climate: SleepIQCoreClimate, time: int +) -> None: + temperature = CoreTemps(core_climate.temperature) + if temperature != CoreTemps.OFF: + await core_climate.turn_on(temperature, time) + + core_climate.timer = time + + +def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str: + sleeper = sleeper_for_side(bed, core_climate.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}" + + +def _get_core_climate_unique_id( + bed: SleepIQBed, core_climate: SleepIQCoreClimate +) -> str: + return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -132,6 +161,20 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, ), + CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( + key=CORE_CLIMATE_TIMER, + native_min_value=0, + native_max_value=600, + native_step=30, + name=ENTITY_TYPES[CORE_CLIMATE_TIMER], + icon="mdi:timer", + value_fn=lambda core_climate: core_climate.timer, + set_value_fn=_async_set_core_climate_time, + get_name_fn=_get_core_climate_name, + get_unique_id_fn=_get_core_climate_unique_id, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + ), } @@ -172,6 +215,15 @@ async def async_setup_entry( ) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQNumberEntity( + data.data_coordinator, + bed, + core_climate, + NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER], + ) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 7d059ba6b59..d4bc9fda3a4 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, Side, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQPreset, ) @@ -15,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FOOT_WARMER +from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side @@ -37,6 +39,10 @@ async def async_setup_entry( SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) @@ -115,3 +121,57 @@ class SleepIQFootWarmingTempSelectEntity( self._attr_current_option = option await self.coordinator.async_request_refresh() self.async_write_ha_state() + + +class SleepIQCoreTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ core climate temperature select entity.""" + + # Maps to translate between asyncsleepiq and HA's naming preference + SLEEPIQ_TO_HA_CORE_TEMP_MAP = { + CoreTemps.OFF: "off", + CoreTemps.HEATING_PUSH_LOW: "heating_low", + CoreTemps.HEATING_PUSH_MED: "heating_medium", + CoreTemps.HEATING_PUSH_HIGH: "heating_high", + CoreTemps.COOLING_PULL_LOW: "cooling_low", + CoreTemps.COOLING_PULL_MED: "cooling_medium", + CoreTemps.COOLING_PULL_HIGH: "cooling_high", + } + HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()} + + _attr_icon = "mdi:heat-wave" + _attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values()) + _attr_translation_key = "core_temps" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + core_climate: SleepIQCoreClimate, + ) -> None: + """Initialize the select entity.""" + self.core_climate = core_climate + sleeper = sleeper_for_side(bed, core_climate.side) + super().__init__(coordinator, bed, sleeper, CORE_CLIMATE) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + sleepiq_option = CoreTemps(self.core_climate.temperature) + self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option] + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] + timer = self.core_climate.timer or 240 + + if temperature == CoreTemps.OFF: + await self.core_climate.turn_off() + else: + await self.core_climate.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 634202d6da8..58a35ea914b 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -33,6 +33,17 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "core_temps": { + "state": { + "off": "[%key:common::state::off%]", + "heating_low": "Heating low", + "heating_medium": "Heating medium", + "heating_high": "Heating high", + "cooling_low": "Cooling low", + "cooling_medium": "Cooling medium", + "cooling_high": "Cooling high" + } } } } diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index a9456bd3cc6..f52f489aec3 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + CoreTemps, FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, @@ -29,6 +31,7 @@ from tests.common import MockConfigEntry BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +CORE_CLIMATE_TIME = 240 SLEEPER_L_ID = "98765" SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" @@ -91,6 +94,7 @@ def mock_bed() -> MagicMock: bed.foundation.lights = [light_1, light_2] bed.foundation.foot_warmers = [] + bed.foundation.core_climates = [] return bed @@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation( preset.options = BED_PRESETS mock_bed.foundation.foot_warmers = [] + mock_bed.foundation.core_climates = [] yield client @@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: foot_warmer_r.timer = FOOT_WARM_TIME foot_warmer_r.temperature = FootWarmingTemps.OFF + core_climate_l = create_autospec(SleepIQCoreClimate) + core_climate_r = create_autospec(SleepIQCoreClimate) + mock_bed.foundation.core_climates = [core_climate_l, core_climate_r] + + core_climate_l.side = Side.LEFT + core_climate_l.timer = CORE_CLIMATE_TIME + core_climate_l.temperature = CoreTemps.COOLING_PULL_MED + + core_climate_r.side = Side.RIGHT + core_climate_r.timer = CORE_CLIMATE_TIME + core_climate_r.temperature = CoreTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f0739aabc9d..dd45cdc2400 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -198,3 +198,42 @@ async def test_foot_warmer_timer( await hass.async_block_till_done() assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 + + +async def test_core_climate_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: + """Test the SleepIQ core climate number values for a bed with two sides.""" + entry = await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert state.state == "240.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 600 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_core_climate_timer" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer", + ATTR_VALUE: 420, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index bbfb612e9cb..17d57eba7d3 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from asyncsleepiq import FootWarmingTemps +from asyncsleepiq import CoreTemps, FootWarmingTemps from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -21,6 +21,7 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + CORE_CLIMATE_TIME, FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, @@ -204,3 +205,77 @@ async def test_foot_warmer( mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ 1 ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) + + +async def test_core_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test the SleepIQ select entity for core climate.""" + entry = await setup_platform(hass, SELECT_DOMAIN) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert state.state == "cooling_medium" + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert state.state == CoreTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate", + ATTR_OPTION: "heating_high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME) From 1315095b4a3aa4cf268834a9d763b0a145e7e0fc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 21 Jul 2025 14:16:03 +0200 Subject: [PATCH 0465/1118] Make spelling of "devolo Home Network" consistent (#149165) --- homeassistant/components/devolo_home_network/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 50177a9b13b..24bf06ac59c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network app on the device dashboard.", "password": "Password you protected the device with." } }, @@ -22,8 +22,8 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device", + "description": "Do you want to add the devolo Home Network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo Home Network device", "data": { "password": "[%key:common::config_flow::data::password%]" }, From 6b489e0ab6897176fb60c8da444a3c46e37112ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:34:12 +0200 Subject: [PATCH 0466/1118] Bump sigstore/cosign-installer from 3.9.1 to 3.9.2 (#148985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5ac2e47789b..82009751763 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.1 + uses: sigstore/cosign-installer@v3.9.2 with: cosign-release: "v2.2.3" From 64f190749a5ecccee7ad57d5a0074ff467caca55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 21 Jul 2025 14:39:42 +0200 Subject: [PATCH 0467/1118] Add Demo Vacuum in entity name (#148629) --- homeassistant/components/demo/vacuum.py | 10 +++++----- tests/components/demo/test_vacuum.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11bf3e3118b..ba00bcaedb9 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -48,11 +48,11 @@ SUPPORT_ALL_SERVICES = ( ) FAN_SPEEDS = ["min", "medium", "high", "max"] -DEMO_VACUUM_COMPLETE = "0_Ground_floor" -DEMO_VACUUM_MOST = "1_First_floor" -DEMO_VACUUM_BASIC = "2_Second_floor" -DEMO_VACUUM_MINIMAL = "3_Third_floor" -DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" +DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" +DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" +DEMO_VACUUM_MINIMAL = "Demo vacuum 3 third floor" +DEMO_VACUUM_NONE = "Demo vacuum 4 fourth floor" async def async_setup_entry( diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 3a627efd3f1..a497bd964ec 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -37,11 +37,15 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".replace(" ", "_").lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".replace(" ", "_").lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".replace(" ", "_").lower() @pytest.fixture From af0480f2a4b808a3c2a5878a5f92052fee5521d8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:33 +0200 Subject: [PATCH 0468/1118] Use OptionsFlowWithReload in slide_local (#149168) --- homeassistant/components/slide_local/__init__.py | 7 ------- homeassistant/components/slide_local/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 4690fe8016c..7d2027a985a 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -21,16 +21,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 96aac1a135c..7593d502bec 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,7 +14,11 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -232,7 +236,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SlideOptionsFlowHandler(OptionsFlow): +class SlideOptionsFlowHandler(OptionsFlowWithReload): """Handle a options flow for slide_local.""" async def async_step_init( From 54fa4d635bf7560240a0d02b4917cf6bc2d5c752 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:48 +0200 Subject: [PATCH 0469/1118] Use OptionsFlowWithReload in sonarr (#149166) --- homeassistant/components/sonarr/__init__.py | 6 ------ homeassistant/components/sonarr/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 960227ff0da..1c786356486 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { "upcoming": CalendarDataUpdateCoordinator( hass, entry, host_configuration, sonarr @@ -126,8 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index e1cedba10e7..278d3fbd7bb 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -152,7 +152,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return data_schema -class SonarrOptionsFlowHandler(OptionsFlow): +class SonarrOptionsFlowHandler(OptionsFlowWithReload): """Handle Sonarr client options.""" async def async_step_init( From 671523feb3493aa575fe78f30710864257138f27 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:52:14 +0200 Subject: [PATCH 0470/1118] Use OptionsFlowWithReload in hyperion (#149163) --- homeassistant/components/hyperion/__init__.py | 6 ------ homeassistant/components/hyperion/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 0f49bacd1ef..60a53193acc 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -266,16 +266,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 72e76ef8667..1ef53ad2951 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_BASE, @@ -431,7 +431,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return HyperionOptionsFlow() -class HyperionOptionsFlow(OptionsFlow): +class HyperionOptionsFlow(OptionsFlowWithReload): """Hyperion options flow.""" def _create_client(self) -> client.HyperionClient: From 2476e7e47c70e2c8cd5138d20dd88d1f208bfcd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Jul 2025 15:27:29 +0200 Subject: [PATCH 0471/1118] Revert setting a user to download translations (#149190) --- script/translations/download.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/translations/download.py b/script/translations/download.py index 6a0d6ba824c..0c9504f44cd 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import os from pathlib import Path import re import subprocess @@ -28,8 +27,6 @@ def run_download_docker(): "-v", f"{DOWNLOAD_DIR}:/opt/dest/locale", "--rm", - "--user", - f"{os.getuid()}:{os.getgid()}", f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}", # Lokalise command "lokalise2", From 102ef257a07461bae22d7831188198cd69db17ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 21 Jul 2025 14:35:35 +0100 Subject: [PATCH 0472/1118] Bump hass-nabucasa from 0.107.1 to 0.108.0 (#149189) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 642bece1b8e..72748efff6e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.107.1"], + "requirements": ["hass-nabucasa==0.108.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 157ee1420fc..aa0e1768d52 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.3 diff --git a/pyproject.toml b/pyproject.toml index 6c732066e41..b1b43c80cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.107.1", + "hass-nabucasa==0.108.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index ed9c100fd3a..e4065bed83e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b7e3fd074b6..ccae2a8f8da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30ad1b2e5fe..b401c61739d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From f3db3ba3c8903ace7d697ffe3d7f8213ec43fce9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 09:36:12 -0400 Subject: [PATCH 0473/1118] Bump pyschlage to 2025.7.3 (#149184) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index c5b91cefd2e..b71afe01e56 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.7.2"] + "requirements": ["pyschlage==2025.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccae2a8f8da..60e64a1ad27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b401c61739d..20c826b73e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 From 80b96b0007afeaad0b7687c8eb823017e43f15f7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 15:40:30 +0200 Subject: [PATCH 0474/1118] Use OptionsFlowWithReload in roku (#149172) --- homeassistant/components/roku/__init__.py | 7 ------- homeassistant/components/roku/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index be0b20c97fb..46149264e55 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -25,16 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 47bc86802d2..b28648589c9 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -202,7 +202,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlow): +class RokuOptionsFlowHandler(OptionsFlowWithReload): """Handle Roku options.""" async def async_step_init( From 40252763d702aa710a3249f95947a1572374bdf8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:31:28 +0200 Subject: [PATCH 0475/1118] Switch to a new library in Onkyo (#148613) --- homeassistant/components/onkyo/__init__.py | 26 +- homeassistant/components/onkyo/config_flow.py | 49 +- homeassistant/components/onkyo/const.py | 234 +------- homeassistant/components/onkyo/manifest.json | 5 +- .../components/onkyo/media_player.py | 510 +++++++----------- .../components/onkyo/quality_scale.yaml | 5 +- homeassistant/components/onkyo/receiver.py | 202 +++---- homeassistant/components/onkyo/services.py | 18 +- homeassistant/components/onkyo/util.py | 8 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/onkyo/__init__.py | 125 ++--- tests/components/onkyo/conftest.py | 227 +++++--- .../onkyo/snapshots/test_media_player.ambr | 203 +++++++ tests/components/onkyo/test_config_flow.py | 321 +++++------ tests/components/onkyo/test_init.py | 84 ++- tests/components/onkyo/test_media_player.py | 230 ++++++++ 17 files changed, 1255 insertions(+), 1004 deletions(-) create mode 100644 homeassistant/components/onkyo/util.py create mode 100644 tests/components/onkyo/snapshots/test_media_player.ambr create mode 100644 tests/components/onkyo/test_media_player.py diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index d0f93012eb7..a4d1ec8f175 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -17,7 +17,7 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import Receiver, async_interview +from .receiver import ReceiverManager, async_interview from .services import DATA_MP_ENTITIES, async_setup_services _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) class OnkyoData: """Config Entry data.""" - receiver: Receiver + manager: ReceiverManager sources: dict[InputSource, str] sound_modes: dict[ListeningMode, str] @@ -50,11 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo host = entry.data[CONF_HOST] - info = await async_interview(host) + try: + info = await async_interview(host) + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc if info is None: raise ConfigEntryNotReady(f"Unable to connect to: {host}") - receiver = await Receiver.async_create(info) + manager = ReceiverManager(hass, entry, info) sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} @@ -62,11 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} - entry.runtime_data = OnkyoData(receiver, sources, sound_modes) + entry.runtime_data = OnkyoData(manager, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await receiver.conn.connect() + if error := await manager.start(): + try: + await error + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc return True @@ -75,9 +82,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo """Unload Onkyo config entry.""" del hass.data[DATA_MP_ENTITIES][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + entry.runtime_data.manager.start_unloading() - receiver = entry.runtime_data.receiver - receiver.conn.close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 2b8f9981e4a..75b0f92043d 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,12 +4,12 @@ from collections.abc import Mapping import logging from typing import Any +from aioonkyo import ReceiverInfo import voluptuous as vol from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -29,6 +29,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import OnkyoConfigEntry from .const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -41,19 +42,20 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import ReceiverInfo, async_discover, async_interview +from .receiver import async_discover, async_interview +from .util import get_meaning _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_DEFAULT: dict[str, str] = {} -LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_DEFAULT: list[InputSource] = [] +LISTENING_MODES_DEFAULT: list[ListeningMode] = [] INPUT_SOURCES_ALL_MEANINGS = { - input_source.value_meaning: input_source for input_source in InputSource + get_meaning(input_source): input_source for input_source in InputSource } LISTENING_MODES_ALL_MEANINGS = { - listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode + get_meaning(listening_mode): listening_mode for listening_mode in ListeningMode } STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( @@ -91,6 +93,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + _LOGGER.debug("Config flow start user") return self.async_show_menu( step_id="user", menu_options=["manual", "eiscp_discovery"] ) @@ -103,10 +106,10 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: host = user_input[CONF_HOST] - _LOGGER.debug("Config flow start manual: %s", host) + _LOGGER.debug("Config flow manual: %s", host) try: info = await async_interview(host) - except Exception: + except OSError: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -156,8 +159,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Config flow start eiscp discovery") try: - infos = await async_discover() - except Exception: + infos = list(await async_discover(self.hass)) + except OSError: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -303,8 +306,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_INPUT_SOURCES: [ + get_meaning(input_source) + for input_source in INPUT_SOURCES_DEFAULT + ], + OPTION_LISTENING_MODES: [ + get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + ], } else: entry_options = reconfigure_entry.options @@ -325,11 +334,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the receiver.""" + _LOGGER.debug("Config flow start reconfigure") return await self.async_step_manual() @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: + def async_get_options_flow(config_entry: OnkyoConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -372,7 +382,10 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): entry_options: Mapping[str, Any] = self.config_entry.options entry_options = { - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_LISTENING_MODES: { + listening_mode.value: get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + }, **entry_options, } @@ -416,11 +429,11 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): suggested_values = { OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning + get_meaning(InputSource(input_source)) for input_source in entry_options[OPTION_INPUT_SOURCES] ], OPTION_LISTENING_MODES: [ - ListeningMode(listening_mode).value_meaning + get_meaning(ListeningMode(listening_mode)) for listening_mode in entry_options[OPTION_LISTENING_MODES] ], } @@ -463,13 +476,13 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): input_sources_schema_dict[ - vol.Required(input_source.value_meaning, default=input_source_name) + vol.Required(get_meaning(input_source), default=input_source_name) ] = TextSelector() listening_modes_schema_dict: dict[Any, Selector] = {} for listening_mode, listening_mode_name in self._listening_modes.items(): listening_modes_schema_dict[ - vol.Required(listening_mode.value_meaning, default=listening_mode_name) + vol.Required(get_meaning(listening_mode), default=listening_mode_name) ] = TextSelector() return self.async_show_form( diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index 851d80c5100..4f5be4238b4 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -1,10 +1,9 @@ """Constants for the Onkyo integration.""" -from enum import Enum import typing -from typing import Literal, Self +from typing import Literal -import pyeiscp +from aioonkyo import HDMIOutputParam, InputSourceParam, ListeningModeParam, Zone DOMAIN = "onkyo" @@ -21,214 +20,37 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 - -class EnumWithMeaning(Enum): - """Enum with meaning.""" - - value_meaning: str - - def __new__(cls, value: str) -> Self: - """Create enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = cls._get_meanings()[value] - - return obj - - @staticmethod - def _get_meanings() -> dict[str, str]: - raise NotImplementedError - - OPTION_INPUT_SOURCES = "input_sources" OPTION_LISTENING_MODES = "listening_modes" -_INPUT_SOURCE_MEANINGS = { - "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", - "01": "VIDEO2 ··· CBL/SAT", - "02": "VIDEO3 ··· GAME/TV ··· GAME", - "03": "VIDEO4 ··· AUX", - "04": "VIDEO5 ··· AUX2 ··· GAME2", - "05": "VIDEO6 ··· PC", - "06": "VIDEO7", - "07": "HIDDEN1 ··· EXTRA1", - "08": "HIDDEN2 ··· EXTRA2", - "09": "HIDDEN3 ··· EXTRA3", - "10": "DVD ··· BD/DVD", - "11": "STRM BOX", - "12": "TV", - "20": "TAPE ··· TV/TAPE", - "21": "TAPE2", - "22": "PHONO", - "23": "CD ··· TV/CD", - "24": "FM", - "25": "AM", - "26": "TUNER", - "27": "MUSIC SERVER ··· P4S ··· DLNA", - "28": "INTERNET RADIO ··· IRADIO FAVORITE", - "29": "USB ··· USB(FRONT)", - "2A": "USB(REAR)", - "2B": "NETWORK ··· NET", - "2D": "AIRPLAY", - "2E": "BLUETOOTH", - "2F": "USB DAC IN", - "30": "MULTI CH", - "31": "XM", - "32": "SIRIUS", - "33": "DAB", - "40": "UNIVERSAL PORT", - "41": "LINE", - "42": "LINE2", - "44": "OPTICAL", - "45": "COAXIAL", - "55": "HDMI 5", - "56": "HDMI 6", - "57": "HDMI 7", - "80": "MAIN SOURCE", +InputSource = InputSourceParam +ListeningMode = ListeningModeParam +HDMIOutput = HDMIOutputParam + +ZONES = { + Zone.MAIN: "Main", + Zone.ZONE2: "Zone 2", + Zone.ZONE3: "Zone 3", + Zone.ZONE4: "Zone 4", } -class InputSource(EnumWithMeaning): - """Receiver input source.""" - - DVR = "00" - CBL = "01" - GAME = "02" - AUX = "03" - GAME2 = "04" - PC = "05" - VIDEO7 = "06" - EXTRA1 = "07" - EXTRA2 = "08" - EXTRA3 = "09" - DVD = "10" - STRM_BOX = "11" - TV = "12" - TAPE = "20" - TAPE2 = "21" - PHONO = "22" - CD = "23" - FM = "24" - AM = "25" - TUNER = "26" - MUSIC_SERVER = "27" - INTERNET_RADIO = "28" - USB = "29" - USB_REAR = "2A" - NETWORK = "2B" - AIRPLAY = "2D" - BLUETOOTH = "2E" - USB_DAC_IN = "2F" - MULTI_CH = "30" - XM = "31" - SIRIUS = "32" - DAB = "33" - UNIVERSAL_PORT = "40" - LINE = "41" - LINE2 = "42" - OPTICAL = "44" - COAXIAL = "45" - HDMI_5 = "55" - HDMI_6 = "56" - HDMI_7 = "57" - MAIN_SOURCE = "80" - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _INPUT_SOURCE_MEANINGS - - -_LISTENING_MODE_MEANINGS = { - "00": "STEREO", - "01": "DIRECT", - "02": "SURROUND", - "03": "FILM ··· GAME RPG ··· ADVANCED GAME", - "04": "THX", - "05": "ACTION ··· GAME ACTION", - "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", - "07": "MONO MOVIE", - "08": "ORCHESTRA ··· CLASSICAL", - "09": "UNPLUGGED", - "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", - "0B": "TV LOGIC ··· DRAMA", - "0C": "ALL CH STEREO ··· EXTENDED STEREO", - "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", - "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", - "0F": "MONO", - "11": "PURE AUDIO ··· PURE DIRECT", - "12": "MULTIPLEX", - "13": "FULL MONO ··· MONO MUSIC", - "14": "DOLBY VIRTUAL/SURROUND ENHANCER", - "15": "DTS SURROUND SENSATION", - "16": "AUDYSSEY DSX", - "17": "DTS VIRTUAL:X", - "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", - "23": "STAGE (JAPAN GENRE CONTROL)", - "25": "ACTION (JAPAN GENRE CONTROL)", - "26": "MUSIC (JAPAN GENRE CONTROL)", - "2E": "SPORTS (JAPAN GENRE CONTROL)", - "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", - "41": "DOLBY EX/DTS ES", - "42": "THX CINEMA", - "43": "THX SURROUND EX", - "44": "THX MUSIC", - "45": "THX GAMES", - "50": "THX U(2)/S(2)/I/S CINEMA", - "51": "THX U(2)/S(2)/I/S MUSIC", - "52": "THX U(2)/S(2)/I/S GAMES", - "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", - "81": "PLII/PLIIx MUSIC", - "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", - "83": "NEO:6/NEO:X MUSIC", - "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", - "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", - "86": "PLII/PLIIx GAME", - "87": "NEURAL SURR", - "88": "NEURAL THX/NEURAL SURROUND", - "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", - "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", - "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", - "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", - "8D": "NEURAL THX CINEMA", - "8E": "NEURAL THX MUSIC", - "8F": "NEURAL THX GAMES", - "90": "PLIIz HEIGHT", - "91": "NEO:6 CINEMA DTS SURROUND SENSATION", - "92": "NEO:6 MUSIC DTS SURROUND SENSATION", - "93": "NEURAL DIGITAL MUSIC", - "94": "PLIIz HEIGHT + THX CINEMA", - "95": "PLIIz HEIGHT + THX MUSIC", - "96": "PLIIz HEIGHT + THX GAMES", - "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", - "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", - "99": "PLIIz HEIGHT + THX U2/S2 GAMES", - "9A": "NEO:X GAME", - "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", - "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", - "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", - "A3": "NEO:6 CINEMA + AUDYSSEY DSX", - "A4": "NEO:6 MUSIC + AUDYSSEY DSX", - "A5": "NEURAL SURROUND + AUDYSSEY DSX", - "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", - "A7": "DOLBY EX + AUDYSSEY DSX", - "FF": "AUTO SURROUND", +LEGACY_HDMI_OUTPUT_MAPPING = { + HDMIOutput.ANALOG: "no,analog", + HDMIOutput.MAIN: "yes,out", + HDMIOutput.SUB: "out-sub,sub,hdbaset", + HDMIOutput.BOTH: "both,sub", + HDMIOutput.BOTH_MAIN: "both", + HDMIOutput.BOTH_SUB: "both", } - -class ListeningMode(EnumWithMeaning): - """Receiver listening mode.""" - - _ignore_ = "ListeningMode _k _v _meaning" - - ListeningMode = vars() - for _k in _LISTENING_MODE_MEANINGS: - ListeningMode["I" + _k] = _k - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _LISTENING_MODE_MEANINGS - - -ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} - -PYEISCP_COMMANDS = pyeiscp.commands.COMMANDS +LEGACY_REV_HDMI_OUTPUT_MAPPING = { + "analog": HDMIOutput.ANALOG, + "both": HDMIOutput.BOTH_SUB, + "hdbaset": HDMIOutput.SUB, + "no": HDMIOutput.ANALOG, + "out": HDMIOutput.MAIN, + "out-sub": HDMIOutput.SUB, + "sub": HDMIOutput.BOTH, + "yes": HDMIOutput.MAIN, +} diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 6f37fb61b44..07834d4cba1 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -3,11 +3,12 @@ "name": "Onkyo", "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", - "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"], + "loggers": ["aioonkyo"], + "requirements": ["aioonkyo==0.2.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index aed7c51af80..2965388236d 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,12 +1,12 @@ -"""Support for Onkyo Receivers.""" +"""Media player platform.""" from __future__ import annotations import asyncio -from enum import Enum -from functools import cache import logging -from typing import Any, Literal +from typing import Any + +from aioonkyo import Code, Kind, Status, Zone, command, query, status from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -14,23 +14,25 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( DOMAIN, + LEGACY_HDMI_OUTPUT_MAPPING, + LEGACY_REV_HDMI_OUTPUT_MAPPING, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, - PYEISCP_COMMANDS, ZONES, InputSource, ListeningMode, VolumeResolution, ) -from .receiver import Receiver +from .receiver import ReceiverManager from .services import DATA_MP_ENTITIES +from .util import get_meaning _LOGGER = logging.getLogger(__name__) @@ -86,64 +88,6 @@ VIDEO_INFORMATION_MAPPING = [ "input_hdr", ] -type LibValue = str | tuple[str, ...] - - -def _get_single_lib_value(value: LibValue) -> str: - if isinstance(value, str): - return value - return value[-1] - - -def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: - result: dict[T, LibValue] = {} - for k, v in cmds["values"].items(): - try: - key = cls(k) - except ValueError: - continue - result[key] = v["name"] - - return result - - -@cache -def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["SLI"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] - case "zone3": - cmds = PYEISCP_COMMANDS["zone3"]["SL3"] - case "zone4": - cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - - return _get_lib_mapping(cmds, InputSource) - - -@cache -def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: - return {value: key for key, value in _input_source_lib_mappings(zone).items()} - - -@cache -def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["LMD"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] - case _: - return {} - - return _get_lib_mapping(cmds, ListeningMode) - - -@cache -def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: - return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} - async def async_setup_entry( hass: HomeAssistant, @@ -153,10 +97,10 @@ async def async_setup_entry( """Set up MediaPlayer for config entry.""" data = entry.runtime_data - receiver = data.receiver + manager = data.manager all_entities = hass.data[DATA_MP_ENTITIES] - entities: dict[str, OnkyoMediaPlayer] = {} + entities: dict[Zone, OnkyoMediaPlayer] = {} all_entities[entry.entry_id] = entities volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] @@ -164,29 +108,33 @@ async def async_setup_entry( sources = data.sources sound_modes = data.sound_modes - def connect_callback(receiver: Receiver) -> None: - if not receiver.first_connect: + async def connect_callback(reconnect: bool) -> None: + if reconnect: for entity in entities.values(): if entity.enabled: - entity.backfill_state() + await entity.backfill_state() + + async def update_callback(message: Status) -> None: + if isinstance(message, status.Raw): + return + + zone = message.zone - def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None: - zone, _, value = message entity = entities.get(zone) if entity is not None: if entity.enabled: entity.process_update(message) - elif zone in ZONES and value != "N/A": - # When we receive the status for a zone, and the value is not "N/A", - # then zone is available on the receiver, so we create the entity for it. + elif not isinstance(message, status.NotAvailable): + # When we receive a valid status for a zone, then that zone is available on the receiver, + # so we create the entity for it. _LOGGER.debug( "Discovered %s on %s (%s)", ZONES[zone], - receiver.model_name, - receiver.host, + manager.info.model_name, + manager.info.host, ) zone_entity = OnkyoMediaPlayer( - receiver, + manager, zone, volume_resolution=volume_resolution, max_volume=max_volume, @@ -196,25 +144,27 @@ async def async_setup_entry( entities[zone] = zone_entity async_add_entities([zone_entity]) - receiver.callbacks.connect.append(connect_callback) - receiver.callbacks.update.append(update_callback) + manager.callbacks.connect.append(connect_callback) + manager.callbacks.update.append(update_callback) class OnkyoMediaPlayer(MediaPlayerEntity): - """Representation of an Onkyo Receiver Media Player (one per each zone).""" + """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False _supports_volume: bool = False - _supports_sound_mode: bool = False + # None means no technical possibility of support + _supports_sound_mode: bool | None = None _supports_audio_info: bool = False _supports_video_info: bool = False - _query_timer: asyncio.TimerHandle | None = None + + _query_task: asyncio.Task | None = None def __init__( self, - receiver: Receiver, - zone: str, + manager: ReceiverManager, + zone: Zone, *, volume_resolution: VolumeResolution, max_volume: float, @@ -222,80 +172,88 @@ class OnkyoMediaPlayer(MediaPlayerEntity): sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" - self._receiver = receiver - name = receiver.model_name - identifier = receiver.identifier - self._attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}" - self._attr_unique_id = f"{identifier}_{zone}" - + self._manager = manager self._zone = zone + name = manager.info.model_name + identifier = manager.info.identifier + self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}" + self._attr_unique_id = f"{identifier}_{zone.value}" + self._volume_resolution = volume_resolution self._max_volume = max_volume - self._options_sources = sources - self._source_lib_mapping = _input_source_lib_mappings(zone) - self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) + zone_sources = InputSource.for_zone(zone) self._source_mapping = { - key: value - for key, value in sources.items() - if key in self._source_lib_mapping + key: value for key, value in sources.items() if key in zone_sources } self._rev_source_mapping = { value: key for key, value in self._source_mapping.items() } - self._options_sound_modes = sound_modes - self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) - self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + zone_sound_modes = ListeningMode.for_zone(zone) self._sound_mode_mapping = { - key: value - for key, value in sound_modes.items() - if key in self._sound_mode_lib_mapping + key: value for key, value in sound_modes.items() if key in zone_sound_modes } self._rev_sound_mode_mapping = { value: key for key, value in self._sound_mode_mapping.items() } + self._hdmi_output_mapping = LEGACY_HDMI_OUTPUT_MAPPING + self._rev_hdmi_output_mapping = LEGACY_REV_HDMI_OUTPUT_MAPPING + self._attr_source_list = list(self._rev_source_mapping) self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_supported_features = SUPPORTED_FEATURES_BASE - if zone == "main": + if zone == Zone.MAIN: self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._supports_volume = True self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._supports_sound_mode = True + elif Code.get_from_kind_zone(Kind.LISTENING_MODE, zone) is not None: + # To be detected later: + self._supports_sound_mode = False self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" - self.backfill_state() + await self.backfill_state() async def async_will_remove_from_hass(self) -> None: """Cancel the query timer when the entity is removed.""" - if self._query_timer: - self._query_timer.cancel() - self._query_timer = None + if self._query_task: + self._query_task.cancel() + self._query_task = None - @callback - def _update_receiver(self, propname: str, value: Any) -> None: - """Update a property in the receiver.""" - self._receiver.conn.update_property(self._zone, propname, value) + async def backfill_state(self) -> None: + """Get the receiver to send all the info we care about. - @callback - def _query_receiver(self, propname: str) -> None: - """Cause the receiver to send an update about a property.""" - self._receiver.conn.query_property(self._zone, propname) + Usually run only on connect, as we can otherwise rely on the + receiver to keep us informed of changes. + """ + await self._manager.write(query.Power(self._zone)) + await self._manager.write(query.Volume(self._zone)) + await self._manager.write(query.Muting(self._zone)) + await self._manager.write(query.InputSource(self._zone)) + await self._manager.write(query.TunerPreset(self._zone)) + if self._supports_sound_mode is not None: + await self._manager.write(query.ListeningMode(self._zone)) + if self._zone == Zone.MAIN: + await self._manager.write(query.HDMIOutput()) + await self._manager.write(query.AudioInformation()) + await self._manager.write(query.VideoInformation()) async def async_turn_on(self) -> None: """Turn the media player on.""" - self._update_receiver("power", "on") + message = command.Power(self._zone, command.Power.Param.ON) + await self._manager.write(message) async def async_turn_off(self) -> None: """Turn the media player off.""" - self._update_receiver("power", "standby") + message = command.Power(self._zone, command.Power.Param.STANDBY) + await self._manager.write(message) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1. @@ -307,28 +265,30 @@ class OnkyoMediaPlayer(MediaPlayerEntity): scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION - self._update_receiver( - "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) - ) + value = round(volume * (self._max_volume / 100) * self._volume_resolution) + message = command.Volume(self._zone, value) + await self._manager.write(message) async def async_volume_up(self) -> None: """Increase volume by 1 step.""" - self._update_receiver("volume", "level-up") + message = command.Volume(self._zone, command.Volume.Param.UP) + await self._manager.write(message) async def async_volume_down(self) -> None: """Decrease volume by 1 step.""" - self._update_receiver("volume", "level-down") + message = command.Volume(self._zone, command.Volume.Param.DOWN) + await self._manager.write(message) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - self._update_receiver( - "audio-muting" if self._zone == "main" else "muting", - "on" if mute else "off", + message = command.Muting( + self._zone, command.Muting.Param.ON if mute else command.Muting.Param.OFF ) + await self._manager.write(message) async def async_select_source(self, source: str) -> None: """Select input source.""" - if not self.source_list or source not in self.source_list: + if source not in self._rev_source_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_source", @@ -338,15 +298,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] - source_lib_single = _get_single_lib_value(source_lib) - self._update_receiver( - "input-selector" if self._zone == "main" else "selector", source_lib_single - ) + message = command.InputSource(self._zone, self._rev_source_mapping[source]) + await self._manager.write(message) async def async_select_sound_mode(self, sound_mode: str) -> None: """Select listening sound mode.""" - if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + if sound_mode not in self._rev_sound_mode_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_sound_mode", @@ -356,197 +313,138 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - sound_mode_lib = self._sound_mode_lib_mapping[ - self._rev_sound_mode_mapping[sound_mode] - ] - sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) - self._update_receiver("listening-mode", sound_mode_lib_single) + message = command.ListeningMode( + self._zone, self._rev_sound_mode_mapping[sound_mode] + ) + await self._manager.write(message) async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" - self._update_receiver("hdmi-output-selector", hdmi_output) + message = command.HDMIOutput(self._rev_hdmi_output_mapping[hdmi_output]) + await self._manager.write(message) async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play radio station by preset number.""" - if self.source is not None: - source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: - self._update_receiver("preset", media_id) - - @callback - def backfill_state(self) -> None: - """Get the receiver to send all the info we care about. - - Usually run only on connect, as we can otherwise rely on the - receiver to keep us informed of changes. - """ - self._query_receiver("power") - self._query_receiver("volume") - self._query_receiver("preset") - if self._zone == "main": - self._query_receiver("hdmi-output-selector") - self._query_receiver("audio-muting") - self._query_receiver("input-selector") - self._query_receiver("listening-mode") - self._query_receiver("audio-information") - self._query_receiver("video-information") - else: - self._query_receiver("muting") - self._query_receiver("selector") - - @callback - def process_update(self, update: tuple[str, str, Any]) -> None: - """Store relevant updates so they can be queried later.""" - zone, command, value = update - if zone != self._zone: + if self.source is None: return - if command in ["system-power", "power"]: - if value == "on": + source = self._rev_source_mapping.get(self.source) + if media_type.lower() != "radio" or source not in PLAYABLE_SOURCES: + return + + message = command.TunerPreset(self._zone, int(media_id)) + await self._manager.write(message) + + def process_update(self, message: status.Known) -> None: + """Process update.""" + match message: + case status.Power(status.Power.Param.ON): self._attr_state = MediaPlayerState.ON - else: + case status.Power(status.Power.Param.STANDBY): self._attr_state = MediaPlayerState.OFF - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_PRESET, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) - elif command in ["volume", "master-volume"] and value != "N/A": - if not self._supports_volume: - self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME - self._supports_volume = True - # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) - volume_level: float = value / ( - self._volume_resolution * self._max_volume / 100 - ) - self._attr_volume_level = min(1, volume_level) - elif command in ["muting", "audio-muting"]: - self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"] and value != "N/A": - self._parse_source(value) - self._query_av_info_delayed() - elif command == "hdmi-output-selector": - self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value) - elif command == "preset": - if self.source is not None and self.source.lower() == "radio": - self._attr_extra_state_attributes[ATTR_PRESET] = value - elif ATTR_PRESET in self._attr_extra_state_attributes: - del self._attr_extra_state_attributes[ATTR_PRESET] - elif command == "listening-mode" and value != "N/A": - if not self._supports_sound_mode: - self._attr_supported_features |= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE + + case status.Volume(volume): + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) + volume_level: float = volume / ( + self._volume_resolution * self._max_volume / 100 ) - self._supports_sound_mode = True - self._parse_sound_mode(value) - self._query_av_info_delayed() - elif command == "audio-information": - self._supports_audio_info = True - self._parse_audio_information(value) - elif command == "video-information": - self._supports_video_info = True - self._parse_video_information(value) - elif command == "fl-display-information": - self._query_av_info_delayed() + self._attr_volume_level = min(1, volume_level) + + case status.Muting(muting): + self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON) + + case status.InputSource(source): + if source in self._source_mapping: + self._attr_source = self._source_mapping[source] + else: + source_meaning = get_meaning(source) + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning + + self._query_av_info_delayed() + + case status.ListeningMode(sound_mode): + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + else: + sound_mode_meaning = get_meaning(sound_mode) + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + + self._query_av_info_delayed() + + case status.HDMIOutput(hdmi_output): + self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ( + self._hdmi_output_mapping[hdmi_output] + ) + self._query_av_info_delayed() + + case status.TunerPreset(preset): + self._attr_extra_state_attributes[ATTR_PRESET] = preset + + case status.AudioInformation(): + self._supports_audio_info = True + audio_information = {} + for item in AUDIO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + audio_information[item] = item_value + self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = ( + audio_information + ) + + case status.VideoInformation(): + self._supports_video_info = True + video_information = {} + for item in VIDEO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + video_information[item] = item_value + self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = ( + video_information + ) + + case status.FLDisplay(): + self._query_av_info_delayed() + + case status.NotAvailable(Kind.AUDIO_INFORMATION): + # Not available right now, but still supported + self._supports_audio_info = True + + case status.NotAvailable(Kind.VIDEO_INFORMATION): + # Not available right now, but still supported + self._supports_video_info = True self.async_write_ha_state() - @callback - def _parse_source(self, source_lib: LibValue) -> None: - source = self._rev_source_lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] - return - - source_meaning = source.value_meaning - - if source not in self._options_sources: - _LOGGER.warning( - 'Input source "%s" for entity: %s is not in the list. Check integration options', - source_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) - - self._attr_source = source_meaning - - @callback - def _parse_sound_mode(self, mode_lib: LibValue) -> None: - sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] - if sound_mode in self._sound_mode_mapping: - self._attr_sound_mode = self._sound_mode_mapping[sound_mode] - return - - sound_mode_meaning = sound_mode.value_meaning - - if sound_mode not in self._options_sound_modes: - _LOGGER.warning( - 'Listening mode "%s" for entity: %s is not in the list. Check integration options', - sound_mode_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) - - self._attr_sound_mode = sound_mode_meaning - - @callback - def _parse_audio_information( - self, audio_information: tuple[str] | Literal["N/A"] - ) -> None: - # If audio information is not available, N/A is returned, - # so only update the audio information, when it is not N/A. - if audio_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = { - name: value - for name, value in zip( - AUDIO_INFORMATION_MAPPING, audio_information, strict=False - ) - if len(value) > 0 - } - - @callback - def _parse_video_information( - self, video_information: tuple[str] | Literal["N/A"] - ) -> None: - # If video information is not available, N/A is returned, - # so only update the video information, when it is not N/A. - if video_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = { - name: value - for name, value in zip( - VIDEO_INFORMATION_MAPPING, video_information, strict=False - ) - if len(value) > 0 - } - def _query_av_info_delayed(self) -> None: - if self._zone == "main" and not self._query_timer: + if self._zone == Zone.MAIN and not self._query_task: - @callback - def _query_av_info() -> None: + async def _query_av_info() -> None: + await asyncio.sleep(AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME) if self._supports_audio_info: - self._query_receiver("audio-information") + await self._manager.write(query.AudioInformation()) if self._supports_video_info: - self._query_receiver("video-information") - self._query_timer = None + await self._manager.write(query.VideoInformation()) + self._query_task = None - self._query_timer = self.hass.loop.call_later( - AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info - ) + self._query_task = asyncio.create_task(_query_av_info()) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 4b9fbe7c019..caf0d33fafc 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -77,7 +77,4 @@ rules: status: exempt comment: | This integration is not making any HTTP requests. - strict-typing: - status: todo - comment: | - The library is not fully typed yet. + strict-typing: done diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index cc6cbbc95fb..e4fe8bc6630 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -3,149 +3,149 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable import contextlib from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING -import pyeiscp +import aioonkyo +from aioonkyo import Instruction, Receiver, ReceiverInfo, Status, connect, query + +from homeassistant.components import network +from homeassistant.core import HomeAssistant from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES +if TYPE_CHECKING: + from . import OnkyoConfigEntry + _LOGGER = logging.getLogger(__name__) @dataclass class Callbacks: - """Onkyo Receiver Callbacks.""" + """Receiver callbacks.""" - connect: list[Callable[[Receiver], None]] = field(default_factory=list) - update: list[Callable[[Receiver, tuple[str, str, Any]], None]] = field( - default_factory=list - ) + connect: list[Callable[[bool], Awaitable[None]]] = field(default_factory=list) + update: list[Callable[[Status], Awaitable[None]]] = field(default_factory=list) + + def clear(self) -> None: + """Clear all callbacks.""" + self.connect.clear() + self.update.clear() -@dataclass -class Receiver: - """Onkyo receiver.""" +class ReceiverManager: + """Receiver manager.""" - conn: pyeiscp.Connection - model_name: str - identifier: str - host: str - first_connect: bool = True - callbacks: Callbacks = field(default_factory=Callbacks) + hass: HomeAssistant + entry: OnkyoConfigEntry + info: ReceiverInfo + receiver: Receiver | None = None + callbacks: Callbacks - @classmethod - async def async_create(cls, info: ReceiverInfo) -> Receiver: - """Set up Onkyo Receiver.""" + _started: asyncio.Event - receiver: Receiver | None = None + def __init__( + self, hass: HomeAssistant, entry: OnkyoConfigEntry, info: ReceiverInfo + ) -> None: + """Init receiver manager.""" + self.hass = hass + self.entry = entry + self.info = info + self.callbacks = Callbacks() + self._started = asyncio.Event() - def on_connect(_origin: str) -> None: - assert receiver is not None - receiver.on_connect() + async def start(self) -> Awaitable[None] | None: + """Start the receiver manager run. - def on_update(message: tuple[str, str, Any], _origin: str) -> None: - assert receiver is not None - receiver.on_update(message) - - _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) - - connection = await pyeiscp.Connection.create( - host=info.host, - port=info.port, - connect_callback=on_connect, - update_callback=on_update, - auto_connect=False, + Returns `None`, if everything went fine. + Returns an awaitable with exception set, if something went wrong. + """ + manager_task = self.entry.async_create_background_task( + self.hass, self._run(), "run_connection" ) - - return ( - receiver := cls( - conn=connection, - model_name=info.model_name, - identifier=info.identifier, - host=info.host, - ) + wait_for_started_task = asyncio.create_task(self._started.wait()) + done, _ = await asyncio.wait( + (manager_task, wait_for_started_task), return_when=asyncio.FIRST_COMPLETED ) + if manager_task in done: + # Something went wrong, so let's return the manager task, + # so that it can be awaited to error out + return manager_task - def on_connect(self) -> None: + return None + + async def _run(self) -> None: + """Run the connection to the receiver.""" + reconnect = False + while True: + try: + async with connect(self.info, retry=reconnect) as self.receiver: + if not reconnect: + self._started.set() + else: + _LOGGER.info("Reconnected: %s", self.info) + + await self.on_connect(reconnect=reconnect) + + while message := await self.receiver.read(): + await self.on_update(message) + + reconnect = True + + finally: + _LOGGER.info("Disconnected: %s", self.info) + + async def on_connect(self, reconnect: bool) -> None: """Receiver (re)connected.""" - _LOGGER.debug("Receiver (re)connected: %s (%s)", self.model_name, self.host) # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. for zone in ZONES: - self.conn.query_property(zone, "power") + await self.write(query.Power(zone)) for callback in self.callbacks.connect: - callback(self) + await callback(reconnect) - self.first_connect = False - - def on_update(self, message: tuple[str, str, Any]) -> None: + async def on_update(self, message: Status) -> None: """Process new message from the receiver.""" - _LOGGER.debug("Received update callback from %s: %s", self.model_name, message) for callback in self.callbacks.update: - callback(self, message) + await callback(message) + async def write(self, message: Instruction) -> None: + """Write message to the receiver.""" + assert self.receiver is not None + await self.receiver.write(message) -@dataclass -class ReceiverInfo: - """Onkyo receiver information.""" - - host: str - port: int - model_name: str - identifier: str + def start_unloading(self) -> None: + """Start unloading.""" + self.callbacks.clear() async def async_interview(host: str) -> ReceiverInfo | None: - """Interview Onkyo Receiver.""" - _LOGGER.debug("Interviewing receiver: %s", host) - - receiver_info: ReceiverInfo | None = None - - event = asyncio.Event() - - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver interviewed, connection not yet active.""" - nonlocal receiver_info - if receiver_info is None: - info = ReceiverInfo(host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host) - receiver_info = info - event.set() - - timeout = DEVICE_INTERVIEW_TIMEOUT - - await pyeiscp.Connection.discover( - host=host, discovery_callback=_callback, timeout=timeout - ) - + """Interview the receiver.""" + info: ReceiverInfo | None = None with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout) - - return receiver_info + async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): + info = await aioonkyo.interview(host) + return info -async def async_discover() -> Iterable[ReceiverInfo]: - """Discover Onkyo Receivers.""" - _LOGGER.debug("Discovering receivers") +async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: + """Discover receivers.""" + all_infos: dict[str, ReceiverInfo] = {} - receiver_infos: list[ReceiverInfo] = [] + async def collect_infos(address: str) -> None: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(DEVICE_DISCOVERY_TIMEOUT): + async for info in aioonkyo.discover(address): + all_infos.setdefault(info.identifier, info) - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver discovered, connection not yet active.""" - info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host) - receiver_infos.append(info) + broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [collect_infos(str(address)) for address in broadcast_addrs] - timeout = DEVICE_DISCOVERY_TIMEOUT + await asyncio.gather(*tasks) - await pyeiscp.Connection.discover(discovery_callback=_callback, timeout=timeout) - - await asyncio.sleep(timeout) - - return receiver_infos + return all_infos.values() diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index 26a22523a0e..cfd246d9af7 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from aioonkyo import Zone import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -12,29 +13,18 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DOMAIN, LEGACY_REV_HDMI_OUTPUT_MAPPING if TYPE_CHECKING: from .media_player import OnkyoMediaPlayer -DATA_MP_ENTITIES: HassKey[dict[str, dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) +DATA_MP_ENTITIES: HassKey[dict[str, dict[Zone, OnkyoMediaPlayer]]] = HassKey(DOMAIN) ATTR_HDMI_OUTPUT = "hdmi_output" -ACCEPTED_VALUES = [ - "no", - "analog", - "yes", - "out", - "out-sub", - "sub", - "hdbaset", - "both", - "up", -] ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), + vol.Required(ATTR_HDMI_OUTPUT): vol.In(LEGACY_REV_HDMI_OUTPUT_MAPPING), } ) SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" diff --git a/homeassistant/components/onkyo/util.py b/homeassistant/components/onkyo/util.py new file mode 100644 index 00000000000..bd2cc8a4c7b --- /dev/null +++ b/homeassistant/components/onkyo/util.py @@ -0,0 +1,8 @@ +"""Utils for Onkyo.""" + +from .const import InputSource, ListeningMode + + +def get_meaning(param: InputSource | ListeningMode) -> str: + """Get param meaning.""" + return " ··· ".join(param.meanings) diff --git a/requirements_all.txt b/requirements_all.txt index 60e64a1ad27..513422df915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,6 +330,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1956,9 +1959,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20c826b73e9..4fac0aba573 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -312,6 +312,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1631,9 +1634,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 689711888d8..f8580c2b257 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -1,90 +1,71 @@ """Tests for the Onkyo integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +from aioonkyo import ReceiverInfo -from homeassistant.components.onkyo.receiver import Receiver, ReceiverInfo -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +RECEIVER_INFO = ReceiverInfo( + host="192.168.0.101", + ip="192.168.0.101", + model_name="TX-NR7100", + identifier="0009B0123456", +) -def create_receiver_info(id: int) -> ReceiverInfo: - """Create an empty receiver info object for testing.""" - return ReceiverInfo( - host=f"host {id}", - port=id, - model_name=f"type {id}", - identifier=f"id{id}", - ) +RECEIVER_INFO_2 = ReceiverInfo( + host="192.168.0.102", + ip="192.168.0.102", + model_name="TX-RZ50", + identifier="0009B0ABCDEF", +) -def create_connection(id: int) -> Mock: - """Create an mock connection object for testing.""" - connection = Mock() - connection.host = f"host {id}" - connection.port = 0 - connection.name = f"type {id}" - connection.identifier = f"id{id}" - return connection +@contextmanager +def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: + """Mock discovery functions.""" + async def get_info(host: str) -> ReceiverInfo | None: + """Get receiver info by host.""" + for info in receiver_infos: + if info.host == host: + return info + return None -def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: - """Create a config entry from receiver info.""" - data = {CONF_HOST: info.host} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } + def get_infos(host: str) -> MagicMock: + """Get receiver infos from broadcast.""" + discover_mock = MagicMock() + discover_mock.__aiter__.return_value = receiver_infos + return discover_mock - return MockConfigEntry( - data=data, - options=options, - title=info.model_name, - domain="onkyo", - unique_id=info.identifier, - ) - - -def create_empty_config_entry() -> MockConfigEntry: - """Create an empty config entry for use in unit tests.""" - data = {CONF_HOST: ""} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } - - return MockConfigEntry( - data=data, - options=options, - title="Unit test Onkyo", - domain="onkyo", - unique_id="onkyo_unique_id", - ) - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, receiver_info: ReceiverInfo -) -> None: - """Fixture for setting up the component.""" - - config_entry.add_to_hass(hass) - - mock_receiver = AsyncMock() - mock_receiver.conn.close = Mock() - mock_receiver.callbacks.connect = Mock() - mock_receiver.callbacks.update = Mock() + discover_kwargs = {} + interview_kwargs = {} + if receiver_infos is None: + discover_kwargs["side_effect"] = OSError + interview_kwargs["side_effect"] = OSError + else: + discover_kwargs["new"] = get_infos + interview_kwargs["new"] = get_info with ( patch( - "homeassistant.components.onkyo.async_interview", - return_value=receiver_info, + "homeassistant.components.onkyo.receiver.aioonkyo.discover", + **discover_kwargs, + ), + patch( + "homeassistant.components.onkyo.receiver.aioonkyo.interview", + **interview_kwargs, ), - patch.object(Receiver, "async_create", return_value=mock_receiver), ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + yield + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index abbe39dd966..c6459a2b1f2 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,74 +1,181 @@ -"""Configure tests for the Onkyo integration.""" +"""Common fixtures for the Onkyo tests.""" -from unittest.mock import patch +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status import pytest from homeassistant.components.onkyo.const import DOMAIN +from homeassistant.const import CONF_HOST -from . import create_connection +from . import RECEIVER_INFO, mock_discovery from tests.common import MockConfigEntry -@pytest.fixture(name="config_entry") +@pytest.fixture(autouse=True) +def mock_default_discovery() -> Generator[None]: + """Mock the discovery functions with default info.""" + with ( + patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=1, + DEVICE_DISCOVERY_TIMEOUT=1, + ), + mock_discovery([RECEIVER_INFO]), + ): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock integration setup.""" + with patch( + "homeassistant.components.onkyo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connect() -> Generator[AsyncMock]: + """Mock an Onkyo connect.""" + with patch( + "homeassistant.components.onkyo.receiver.connect", + ) as connect: + yield connect.return_value.__aenter__ + + +INITIAL_MESSAGES = [ + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Volume(Code.from_kind_zone(Kind.VOLUME, Zone.ZONE2), None, 50), + status.Muting( + Code.from_kind_zone(Kind.MUTING, Zone.MAIN), None, status.Muting.Param.OFF + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.MAIN), + None, + status.InputSource.Param("24"), + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.ZONE2), + None, + status.InputSource.Param("00"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.MAIN), + None, + status.ListeningMode.Param("01"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.ZONE2), + None, + status.ListeningMode.Param("00"), + ), + status.HDMIOutput( + Code.from_kind_zone(Kind.HDMI_OUTPUT, Zone.MAIN), + None, + status.HDMIOutput.Param.MAIN, + ), + status.TunerPreset(Code.from_kind_zone(Kind.TUNER_PRESET, Zone.MAIN), None, 1), + status.AudioInformation( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + auto_phase_control_phase="Normal", + ), + status.VideoInformation( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + input_color_depth="24bit", + ), + status.FLDisplay(Code.from_kind_zone(Kind.FL_DISPLAY, Zone.MAIN), None, "LALALA"), + status.NotAvailable( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + Kind.AUDIO_INFORMATION, + ), + status.NotAvailable( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + Kind.VIDEO_INFORMATION, + ), + status.Raw(None, None), +] + + +@pytest.fixture +def read_queue() -> asyncio.Queue[Status | None]: + """Read messages queue.""" + return asyncio.Queue() + + +@pytest.fixture +def writes() -> list[Instruction]: + """Written messages.""" + return [] + + +@pytest.fixture +def mock_receiver( + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], + writes: list[Instruction], +) -> AsyncMock: + """Mock an Onkyo receiver.""" + receiver_class = AsyncMock(Receiver, auto_spec=True) + receiver = receiver_class.return_value + + for message in INITIAL_MESSAGES: + read_queue.put_nowait(message) + + async def read() -> Status: + return await read_queue.get() + + async def write(message: Instruction) -> None: + writes.append(message) + + receiver.read = read + receiver.write = write + + mock_connect.return_value = receiver + + return receiver + + +@pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Create Onkyo entry in Home Assistant.""" + """Mock a config entry.""" + data = {CONF_HOST: RECEIVER_INFO.host} + options = { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": {"12": "TV", "24": "FM Radio"}, + "listening_modes": {"00": "Stereo", "04": "THX"}, + } + return MockConfigEntry( domain=DOMAIN, - title="Onkyo", - data={}, + title=RECEIVER_INFO.model_name, + unique_id=RECEIVER_INFO.identifier, + data=data, + options=options, ) - - -@pytest.fixture(autouse=True) -def patch_timeouts(): - """Patch timeouts to avoid tests waiting.""" - with patch.multiple( - "homeassistant.components.onkyo.receiver", - DEVICE_INTERVIEW_TIMEOUT=0, - DEVICE_DISCOVERY_TIMEOUT=0, - ): - yield - - -@pytest.fixture -async def default_mock_discovery(): - """Mock discovery with a single device.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(create_connection(1)) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def stub_mock_discovery(): - """Mock discovery with no devices.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - pass - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def empty_mock_discovery(): - """Mock discovery with an empty connection.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(None) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..1504952a86d --- /dev/null +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -0,0 +1,203 @@ +# serializer version: 1 +# name: test_entities[media_player.tx_nr7100-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100', + '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': 'TX-NR7100', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'audio_information': dict({ + 'auto_phase_control_phase': 'Normal', + }), + 'friendly_name': 'TX-NR7100', + 'is_volume_muted': False, + 'preset': 1, + 'sound_mode': 'DIRECT', + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source': 'FM Radio', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'video_information': dict({ + 'input_color_depth': '24bit', + }), + 'video_out': 'yes,out', + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_2', + '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': 'TX-NR7100 Zone 2', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 2', + 'sound_mode': 'Stereo', + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source': 'VIDEO1 ··· VCR/DVR ··· STB/DVR', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'volume_level': 0.625, + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_3', + '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': 'TX-NR7100 Zone 3', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 3', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 92a4a34e8fb..df10e266982 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,11 +1,9 @@ """Test Onkyo config flow.""" -from unittest.mock import patch - +from aioonkyo import ReceiverInfo import pytest from homeassistant import config_entries -from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -23,17 +21,15 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) -from . import ( - create_config_entry_from_info, - create_connection, - create_empty_config_entry, - create_receiver_info, - setup_integration, -) +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry +def _entry_title(receiver_info: ReceiverInfo) -> str: + return f"{receiver_info.model_name} ({receiver_info.host})" + + async def test_user_initial_menu(hass: HomeAssistant) -> None: """Test initial menu.""" init_result = await hass.config_entries.flow.async_init( @@ -46,7 +42,7 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None: assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} -async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: +async def test_manual_valid_host(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -60,14 +56,16 @@ async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> select_result = await hass.config_entries.flow.async_configure( form_result["flow_id"], - user_input={CONF_HOST: "host 1"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" + assert select_result["description_placeholders"]["name"] == _entry_title( + RECEIVER_INFO + ) -async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: +async def test_manual_invalid_host(hass: HomeAssistant) -> None: """Test invalid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -79,18 +77,17 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + with mock_discovery([]): + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "cannot_connect" -async def test_manual_valid_host_unexpected_error( - hass: HomeAssistant, empty_mock_discovery -) -> None: +async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( @@ -103,112 +100,102 @@ async def test_manual_valid_host_unexpected_error( {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + with mock_discovery(None): + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "unknown" -async def test_discovery_and_no_devices_discovered( - hass: HomeAssistant, stub_mock_discovery -) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( +async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None: + """Test eiscp discovery with no devices found.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" - - -async def test_discovery_with_exception( - hass: HomeAssistant, empty_mock_discovery -) -> None: - """Test discovery which throws an unexpected exception.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" - - -async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: - """Test discovery with a new and an existing entry.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(1)) - await discovery_callback(create_connection(2)) - - with ( - patch("pyeiscp.Connection.discover", new=mock_discover), - # Fake it like the first entry was already added - patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + with mock_discovery([]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"}, ) - assert form_result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema + +async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None: + """Test eiscp discovery with an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with mock_discovery(None): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test eiscp discovery.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) + + assert result["type"] is FlowResultType.FORM + + assert result["data_schema"] is not None + schema = result["data_schema"].schema container = schema["device"].container - assert container == {"id2": "type 2 (host 2)"} + assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} - -async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: - """Test discovery after a selection.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"device": RECEIVER_INFO_2.identifier}, ) - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(42)) - await discovery_callback(create_connection(0)) + assert result["step_id"] == "configure_receiver" - with patch("pyeiscp.Connection.discover", new=mock_discover): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, + ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={"device": "id42"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier -async def test_ssdp_discovery_success( - hass: HomeAssistant, default_mock_discovery -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: """Test SSDP discovery with valid host.""" discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location="http://192.168.0.101:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", @@ -224,7 +211,7 @@ async def test_ssdp_discovery_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" - select_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "volume_resolution": 200, @@ -233,24 +220,19 @@ async def test_ssdp_discovery_success( }, ) - assert select_result["type"] is FlowResultType.CREATE_ENTRY - assert select_result["data"]["host"] == "192.168.1.100" - assert select_result["result"].unique_id == "id1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO.host + assert result["result"].unique_id == RECEIVER_INFO.identifier async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location="http://192.168.0.101:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", @@ -276,10 +258,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: ssdp_st="mock_st", ) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=OSError, - ): + with mock_discovery(None): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -290,9 +269,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: assert result["reason"] == "unknown" -async def test_ssdp_discovery_host_none_info( - hass: HomeAssistant, stub_mock_discovery -) -> None: +async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", @@ -301,19 +278,18 @@ async def test_ssdp_discovery_host_none_info( ssdp_st="mock_st", ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) + with mock_discovery([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" -async def test_ssdp_discovery_no_location( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: """Test SSDP discovery with no location.""" discovery_info = SsdpServiceInfo( ssdp_location=None, @@ -332,9 +308,7 @@ async def test_ssdp_discovery_no_location( assert result["reason"] == "unknown" -async def test_ssdp_discovery_no_host( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: """Test SSDP discovery with no host.""" discovery_info = SsdpServiceInfo( ssdp_location="http://", @@ -353,9 +327,7 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_no_resolution( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_configure_no_resolution(hass: HomeAssistant) -> None: """Test receiver configure with no resolution set.""" init_result = await hass.config_entries.flow.async_init( @@ -380,9 +352,9 @@ async def test_configure_no_resolution( ) -async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -395,7 +367,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) result = await hass.config_entries.flow.async_configure( @@ -437,9 +409,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: } -async def test_configure_invalid_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: """Test receiver configure with invalid resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -464,22 +434,23 @@ async def test_configure_invalid_resolution_set( ) -async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) + await setup_integration(hass, mock_config_entry) - old_host = config_entry.data[CONF_HOST] - old_options = config_entry.options + old_host = mock_config_entry.data[CONF_HOST] + old_options = mock_config_entry.options - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info.host} + result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} ) await hass.async_block_till_done() @@ -494,36 +465,28 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reconfigure_successful" - assert config_entry.data[CONF_HOST] == old_host - assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 + assert mock_config_entry.data[CONF_HOST] == old_host + assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 for option, option_value in old_options.items(): if option == OPTION_VOLUME_RESOLUTION: continue - assert config_entry.options[option] == option_value + assert mock_config_entry.options[option] == option_value -async def test_reconfigure_new_device(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_new_device( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow with new device.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) + await setup_integration(hass, mock_config_entry) - old_unique_id = receiver_info.identifier + old_unique_id = mock_config_entry.unique_id - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) - mock_connection = create_connection(2) - - # Create mock discover that calls callback immediately - async def mock_discover(host, discovery_callback, timeout): - await discovery_callback(mock_connection) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): + with mock_discovery([RECEIVER_INFO_2]): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_connection.host} + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) await hass.async_block_till_done() @@ -531,9 +494,10 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: assert result2["reason"] == "unique_id_mismatch" # unique id should remain unchanged - assert config_entry.unique_id == old_unique_id + assert mock_config_entry.unique_id == old_unique_id +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( "ignore_missing_translations", [ @@ -545,16 +509,15 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: ] ], ) -async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test options flow.""" + await setup_integration(hass, mock_config_entry) - receiver_info = create_receiver_info(1) - config_entry = create_empty_config_entry() - await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = mock_config_entry.options[OPTION_VOLUME_RESOLUTION] - old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] - - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 4c6ddcca214..144947dcbe1 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -2,51 +2,85 @@ from __future__ import annotations -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock +from aioonkyo import Status import pytest -from homeassistant.components.onkyo import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from . import create_empty_config_entry, create_receiver_info, setup_integration +from . import mock_discovery, setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_receiver") async def test_load_unload_entry( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_no_connection( +@pytest.mark.parametrize( + "receiver_infos", + [ + None, + [], + ], +) +async def test_initialization_failure( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, + receiver_infos, ) -> None: - """Test update options.""" + """Test initialization failure.""" + with mock_discovery(receiver_infos): + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - config_entry.add_to_hass(hass) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - with ( - patch( - "homeassistant.components.onkyo.async_interview", - return_value=None, - ), - pytest.raises(ConfigEntryNotReady), - ): - await async_setup_entry(hass, config_entry) + +async def test_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> None: + """Test connection failure.""" + mock_connect.side_effect = OSError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_receiver") +async def test_reconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], +) -> None: + """Test reconnect.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect.reset_mock() + + assert mock_connect.call_count == 0 + + read_queue.put_nowait(None) # Simulate a disconnect + await asyncio.sleep(0) + + assert mock_connect.call_count == 1 diff --git a/tests/components/onkyo/test_media_player.py b/tests/components/onkyo/test_media_player.py new file mode 100644 index 00000000000..3d22e3b1af8 --- /dev/null +++ b/tests/components/onkyo/test_media_player.py @@ -0,0 +1,230 @@ +"""Test Onkyo media player platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from aioonkyo import Instruction, Zone, command +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.components.onkyo.services import ( + ATTR_HDMI_OUTPUT, + SERVICE_SELECT_HDMI_OUTPUT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "media_player.tx_nr7100" +ENTITY_ID_ZONE_2 = "media_player.tx_nr7100_zone_2" +ENTITY_ID_ZONE_3 = "media_player.tx_nr7100_zone_3" + + +@pytest.fixture(autouse=True) +async def auto_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: AsyncMock, + writes: list[Instruction], +) -> AsyncGenerator[None]: + """Auto setup integration.""" + with ( + patch( + "homeassistant.components.onkyo.media_player.AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME", + 0, + ), + patch("homeassistant.components.onkyo.PLATFORMS", [Platform.MEDIA_PLAYER]), + ): + await setup_integration(hass, mock_config_entry) + writes.clear() + yield + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("action", "action_data", "message"), + [ + (SERVICE_TURN_ON, {}, command.Power(Zone.MAIN, command.Power.Param.ON)), + (SERVICE_TURN_OFF, {}, command.Power(Zone.MAIN, command.Power.Param.STANDBY)), + ( + SERVICE_VOLUME_SET, + {ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + command.Volume(Zone.MAIN, 40), + ), + (SERVICE_VOLUME_UP, {}, command.Volume(Zone.MAIN, command.Volume.Param.UP)), + (SERVICE_VOLUME_DOWN, {}, command.Volume(Zone.MAIN, command.Volume.Param.DOWN)), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: True}, + command.Muting(Zone.MAIN, command.Muting.Param.ON), + ), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: False}, + command.Muting(Zone.MAIN, command.Muting.Param.OFF), + ), + ], +) +async def test_actions( + hass: HomeAssistant, + writes: list[Instruction], + action: str, + action_data: dict, + message: Instruction, +) -> None: + """Test actions.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_ID, **action_data}, + blocking=True, + ) + assert writes[0] == message + + +async def test_select_source(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test select source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "TV"}, + blocking=True, + ) + assert writes[0] == command.InputSource(Zone.MAIN, command.InputSource.Param("12")) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "InvalidSource"}, + blocking=True, + ) + assert not writes + + +async def test_select_sound_mode( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select sound mode.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "THX"}, + blocking=True, + ) + assert writes[0] == command.ListeningMode( + Zone.MAIN, command.ListeningMode.Param("04") + ) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "InvalidMode"}, + blocking=True, + ) + assert not writes + + +async def test_play_media(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test play media (radio preset).""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert writes[0] == command.TunerPreset(Zone.MAIN, 5) + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_2, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_3, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + +async def test_select_hdmi_output( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select hdmi output.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_HDMI_OUTPUT, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HDMI_OUTPUT: "sub"}, + blocking=True, + ) + assert writes[0] == command.HDMIOutput(command.HDMIOutput.Param.BOTH) From 3c70932357bedb48128b9d59ce7e345fc9193d05 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 16:52:25 +0200 Subject: [PATCH 0476/1118] Use OptionsFlowWithReload in enphase_envoy (#149171) --- homeassistant/components/enphase_envoy/__init__.py | 9 --------- homeassistant/components/enphase_envoy/config_flow.py | 4 ++-- tests/components/enphase_envoy/test_init.py | 3 ++- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index f43d89aa098..e95ab1179e1 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from pyenphase import Envoy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -47,17 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when it is updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool: """Unload a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5b7bb98527c..9ba11eafa5d 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -335,7 +335,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EnvoyOptionsFlowHandler(OptionsFlow): +class EnvoyOptionsFlowHandler(OptionsFlowWithReload): """Envoy config flow options handler.""" async def async_step_init( diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index c43be96d8b1..2aa18c991a6 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -509,7 +509,7 @@ async def test_coordinator_interface_information( # verify first time add of mac to connections is in log assert "added connection" in caplog.text - # trigger integration reload by changing options + # update options and reload hass.config_entries.async_update_entry( config_entry, options={ @@ -517,6 +517,7 @@ async def test_coordinator_interface_information( OPTION_DISABLE_KEEP_ALIVE: True, }, ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED From 3f42911af4291c2e5d85806e394f02a5f62bc733 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 21 Jul 2025 10:33:23 -0500 Subject: [PATCH 0477/1118] Add streaming to cloud TTS (#148925) --- homeassistant/components/cloud/tts.py | 35 +++- tests/components/cloud/test_tts.py | 244 ++++++++++++++++++++------ 2 files changed, 218 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 85ca599fa87..179f467922f 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -17,6 +17,8 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, TextToSpeechEntity, + TTSAudioRequest, + TTSAudioResponse, TtsAudioType, Voice, ) @@ -332,7 +334,7 @@ class CloudTTSEntity(TextToSpeechEntity): def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { - ATTR_AUDIO_OUTPUT: AudioOutput.MP3, + ATTR_AUDIO_OUTPUT: AudioOutput.MP3.value, } @property @@ -433,6 +435,29 @@ class CloudTTSEntity(TextToSpeechEntity): return (options[ATTR_AUDIO_OUTPUT], data) + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message.""" + data_gen = self.cloud.voice.process_tts_stream( + text_stream=request.message_gen, + **_prepare_voice_args( + hass=self.hass, + language=request.language, + voice=request.options.get( + ATTR_VOICE, + ( + self._voice + if request.language == self._language + else DEFAULT_VOICES[request.language] + ), + ), + gender=request.options.get(ATTR_GENDER), + ), + ) + + return TTSAudioResponse(AudioOutput.WAV.value, data_gen) + class CloudProvider(Provider): """Home Assistant Cloud speech API provider.""" @@ -526,9 +551,11 @@ class CloudProvider(Provider): language=language, voice=options.get( ATTR_VOICE, - self._voice - if language == self._language - else DEFAULT_VOICES[language], + ( + self._voice + if language == self._language + else DEFAULT_VOICES[language] + ), ), gender=options.get(ATTR_GENDER), ), diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index c920fdac264..44430f9c39a 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,10 +1,12 @@ """Tests for cloud tts.""" -from collections.abc import AsyncGenerator, Callable, Coroutine +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Coroutine from copy import deepcopy from http import HTTPStatus +import io from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import wave from hass_nabucasa.voice import VoiceError, VoiceTokenError from hass_nabucasa.voice_data import TTS_VOICES @@ -239,6 +241,12 @@ async def test_get_tts_audio( side_effect=mock_process_tts_side_effect, ) cloud.voice.process_tts = mock_process_tts + + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -262,13 +270,27 @@ async def test_get_tts_audio( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" @pytest.mark.parametrize( @@ -321,10 +343,10 @@ async def test_get_tts_audio_logged_out( @pytest.mark.parametrize( - ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + ("mock_process_tts_side_effect"), [ - (b"", None), - (None, VoiceError("Boom!")), + (None,), + (VoiceError("Boom!"),), ], ) async def test_tts_entity( @@ -332,15 +354,13 @@ async def test_tts_entity( hass_client: ClientSessionGenerator, entity_registry: EntityRegistry, cloud: MagicMock, - mock_process_tts_return_value: bytes | None, mock_process_tts_side_effect: Exception | None, ) -> None: """Test text-to-speech entity.""" - mock_process_tts = AsyncMock( - return_value=mock_process_tts_return_value, - side_effect=mock_process_tts_side_effect, - ) - cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + if mock_process_tts_side_effect: + mock_process_tts_stream.side_effect = mock_process_tts_side_effect + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -372,13 +392,14 @@ async def test_tts_entity( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == "en-US" - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == "JennyNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == "en-US" + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == "JennyNeural" state = hass.states.get(entity_id) assert state @@ -482,6 +503,8 @@ async def test_deprecated_voice( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -509,18 +532,34 @@ async def test_deprecated_voice( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue( "cloud", f"deprecated_voice_{replacement_voice}" ) assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated voice. data["options"] = {"voice": deprecated_voice} @@ -538,15 +577,30 @@ async def test_deprecated_voice( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = f"deprecated_voice_{deprecated_voice}" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] is None - assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] is None + assert mock_process_tts_stream.call_args.kwargs["voice"] == replacement_voice + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] is None + assert mock_process_tts.call_args.kwargs["voice"] == replacement_voice + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.8.0" @@ -623,6 +677,8 @@ async def test_deprecated_gender( return_value=b"", ) cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -649,15 +705,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + # Force streaming + await client.get(response["path"]) + + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", "deprecated_gender") assert issue is None mock_process_tts.reset_mock() + mock_process_tts_stream.reset_mock() # Test with deprecated gender option. data["options"] = {"gender": gender_option} @@ -675,15 +746,30 @@ async def test_deprecated_gender( } await hass.async_block_till_done() + # Force streaming + await client.get(response["path"]) + issue_id = "deprecated_gender" - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == language - assert mock_process_tts.call_args.kwargs["gender"] == gender_option - assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if data.get("engine_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert mock_process_tts_stream.call_args.kwargs["language"] == language + assert mock_process_tts_stream.call_args.kwargs["gender"] == gender_option + assert mock_process_tts_stream.call_args.kwargs["voice"] == "XiaoxiaoNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert mock_process_tts.call_args.kwargs["language"] == language + assert mock_process_tts.call_args.kwargs["gender"] == gender_option + assert mock_process_tts.call_args.kwargs["voice"] == "XiaoxiaoNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + issue = issue_registry.async_get_issue("cloud", issue_id) assert issue is not None assert issue.breaks_in_ha_version == "2024.10.0" @@ -772,6 +858,8 @@ async def test_tts_services( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) mock_process_tts = AsyncMock(return_value=b"") cloud.voice.process_tts = mock_process_tts + mock_process_tts_stream = _make_stream_mock("There is someone at the door.") + cloud.voice.process_tts_stream = mock_process_tts_stream assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() @@ -793,9 +881,51 @@ async def test_tts_services( assert response.status == HTTPStatus.OK await hass.async_block_till_done() - assert mock_process_tts.call_count == 1 - assert mock_process_tts.call_args is not None - assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." - assert mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] - assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" - assert mock_process_tts.call_args.kwargs["output"] == "mp3" + if service_data.get("entity_id", "").startswith("tts."): + # Streaming + assert mock_process_tts_stream.call_count == 1 + assert mock_process_tts_stream.call_args is not None + assert ( + mock_process_tts_stream.call_args.kwargs["language"] + == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts_stream.call_args.kwargs["voice"] == "GadisNeural" + else: + # Non-streaming + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert ( + mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + ) + assert ( + mock_process_tts.call_args.kwargs["language"] == service_data[ATTR_LANGUAGE] + ) + assert mock_process_tts.call_args.kwargs["voice"] == "GadisNeural" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +def _make_stream_mock(expected_text: str) -> MagicMock: + """Create a mock TTS stream generator with just a WAV header.""" + with io.BytesIO() as wav_io: + wav_writer: wave.Wave_write = wave.open(wav_io, "wb") + with wav_writer: + wav_writer.setframerate(24000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + + wav_io.seek(0) + wav_bytes = wav_io.getvalue() + + process_tts_stream = MagicMock() + + async def fake_process_tts_stream(*, text_stream: AsyncIterable[str], **kwargs): + # Verify text + actual_text = "".join([text_chunk async for text_chunk in text_stream]) + assert actual_text == expected_text + + # WAV header + yield wav_bytes + + process_tts_stream.side_effect = fake_process_tts_stream + + return process_tts_stream From b85ec55abb3b417e6283e1c41d8916fbe4253224 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:41:56 -0400 Subject: [PATCH 0478/1118] Add availability template to template helper config flow (#147623) Co-authored-by: Norbert Rittel Co-authored-by: Joostlek Co-authored-by: Erik Montnemery --- .../components/template/config_flow.py | 28 +++- homeassistant/components/template/const.py | 1 + homeassistant/components/template/helpers.py | 4 + .../components/template/strings.json | 128 ++++++++++++++++++ tests/components/template/test_config_flow.py | 63 +++++++-- 5 files changed, 208 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index d6fc5768f81..bb5ee14c7d2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -30,6 +30,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -53,7 +54,14 @@ from .alarm_control_panel import ( async_create_preview_alarm_control_panel, ) from .binary_sensor import async_create_preview_binary_sensor -from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_AVAILABILITY, + CONF_PRESS, + CONF_TURN_OFF, + CONF_TURN_ON, + DOMAIN, +) from .number import ( CONF_MAX, CONF_MIN, @@ -214,7 +222,17 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), } - schema[vol.Optional(CONF_DEVICE_ID)] = selector.DeviceSelector() + schema |= { + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), + } + ), + {"collapsed": True}, + ), + } return vol.Schema(schema) @@ -530,7 +548,11 @@ def ws_start_preview( ) return - preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) + config: dict = msg["user_input"] + advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + preview_entity = CREATE_PREVIEW_ENTITY[template_type]( + hass, name, {**config, **advanced_options} + ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index e3e0e4fe9f5..2180567bf59 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +CONF_ADVANCED_OPTIONS = "advanced_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index c0177e9dd5d..25f7011c794 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -33,6 +33,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + CONF_ADVANCED_OPTIONS, CONF_ATTRIBUTE_TEMPLATES, CONF_ATTRIBUTES, CONF_AVAILABILITY, @@ -248,6 +249,9 @@ async def async_setup_template_entry( options = dict(config_entry.options) options.pop("template_type") + if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): + options = {**options, **advanced_options} + if replace_value_template and CONF_VALUE_TEMPLATE in options: options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7f285b4929b..a8c2e7660dc 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -19,6 +19,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template alarm control panel" }, "binary_sensor": { @@ -31,6 +39,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template binary sensor" }, "button": { @@ -43,6 +59,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template button" }, "image": { @@ -55,6 +79,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template image" }, "number": { @@ -71,6 +103,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template number" }, "select": { @@ -84,6 +124,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template select" }, "sensor": { @@ -98,6 +146,14 @@ "data_description": { "device_id": "Select a device to link to this entity." }, + "sections": { + "advanced_options": { + "name": "Advanced options", + "data": { + "availability": "Availability template" + } + } + }, "title": "Template sensor" }, "user": { @@ -126,6 +182,14 @@ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "Template switch" } } @@ -149,6 +213,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" }, "binary_sensor": { @@ -159,6 +231,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, "button": { @@ -169,6 +249,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::button::title%]" }, "image": { @@ -180,6 +268,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::image::title%]" }, "number": { @@ -195,6 +291,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::number::title%]" }, "select": { @@ -208,6 +312,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::select::title%]" }, "sensor": { @@ -221,6 +333,14 @@ "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::sensor::title%]" }, "switch": { @@ -235,6 +355,14 @@ "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "data": { + "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + } + } + }, "title": "[%key:component::template::config::step::switch::title%]" } } diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c4e24ddf71..22acb1b2292 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -8,7 +8,7 @@ from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr @@ -217,16 +217,14 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type + availability = {"advanced_options": {"availability": "{{ True }}"}} + with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "name": "My template", - **state_template, - **extra_input, - }, + {"name": "My template", **state_template, **extra_input, **availability}, ) await hass.async_block_till_done() @@ -238,6 +236,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } assert len(mock_setup_entry.mock_calls) == 1 @@ -248,6 +247,7 @@ async def test_config_flow( "template_type": template_type, **state_template, **extra_options, + **availability, } state = hass.states.get(f"{template_type}.my_template") @@ -675,7 +675,7 @@ async def test_options( "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["", STATE_UNAVAILABLE, "50.0"], + ["", STATE_UNKNOWN, "50.0"], [{}, {}], [["one", "two"], ["one", "two"]], ), @@ -695,6 +695,9 @@ async def test_config_flow_preview( """Test the config flow preview.""" client = await hass_ws_client(hass) + hass.states.async_set("binary_sensor.available", "on") + await hass.async_block_till_done() + input_entities = ["one", "two"] result = await hass.config_entries.flow.async_init( @@ -712,12 +715,22 @@ async def test_config_flow_preview( assert result["errors"] is None assert result["preview"] == "template" + availability = { + "advanced_options": { + "availability": "{{ is_state('binary_sensor.available', 'on') }}" + } + } + await client.send_json_auto_id( { "type": "template/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", - "user_input": {"name": "My template", "state": state_template} + "user_input": { + "name": "My template", + "state": state_template, + **availability, + } | extra_user_input, } ) @@ -725,13 +738,16 @@ async def test_config_flow_preview( assert msg["success"] assert msg["result"] is None + entities = [f"{template_type}.{_id}" for _id in listeners[0]] + entities.append("binary_sensor.available") + msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], "listeners": { "all": False, "domains": [], - "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "entities": unordered(entities), "time": False, }, "state": template_states[0], @@ -743,6 +759,9 @@ async def test_config_flow_preview( ) await hass.async_block_till_done() + entities = [f"{template_type}.{_id}" for _id in listeners[1]] + entities.append("binary_sensor.available") + for template_state in template_states[1:]: msg = await client.receive_json() assert msg["event"] == { @@ -752,14 +771,32 @@ async def test_config_flow_preview( "listeners": { "all": False, "domains": [], - "entities": unordered( - [f"{template_type}.{_id}" for _id in listeners[1]] - ), + "entities": unordered(entities), "time": False, }, "state": template_state, } - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 + + # Test preview availability. + hass.states.async_set("binary_sensor.available", "off") + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} + | extra_attributes[0] + | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered(entities), + "time": False, + }, + "state": STATE_UNAVAILABLE, + } + + assert len(hass.states.async_all()) == 3 EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of template')" From 3bd70a4698ff0964e64e4f465864f09aa4c2f2d9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 17:51:26 +0200 Subject: [PATCH 0479/1118] Improve derivative sensor tests (#149179) --- tests/components/derivative/test_sensor.py | 60 +++++++++++++++++----- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index ee458ea54cd..211e6f673ca 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -16,8 +16,15 @@ from homeassistant.const import ( UnitOfPower, UnitOfTime, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -98,6 +105,14 @@ async def test_no_change( attributes: list[dict[str, Any]], ) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.derivative", _capture_event) + config = { "sensor": { "platform": "derivative", @@ -110,6 +125,7 @@ async def test_no_change( } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() entity_id = config["sensor"]["source"] base = dt_util.utcnow() @@ -125,8 +141,16 @@ async def test_no_change( state = hass.states.get("sensor.derivative") assert state is not None + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] # Testing a energy sensor at 1 kWh for 1hour = 0kW - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + assert states == ["unavailable", 0.0, 1.0, 0.0] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == "kW" @@ -268,6 +292,14 @@ async def test_data_moving_average_with_zeroes( # Therefore, we can expect the derivative to peak at 1 after 10 minutes # and then fall down to 0 in steps of 10%. + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.power", _capture_event) + temperature_values = [] for temperature in range(10): temperature_values += [temperature] @@ -296,19 +328,23 @@ async def test_data_moving_average_with_zeroes( hass.states.async_set( entity_id, value, extra_attributes, force_update=force_update ) - await hass.async_block_till_done() - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) + await hass.async_block_till_done() + await hass.async_block_till_done() - if time_window == time: - assert derivative == 1.0 - elif time_window < time < time_window * 2: - assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) - elif time == time_window * 2: - assert derivative == 0 + assert len(events[2:]) == len(times) + for time, event in zip(times, events[2:], strict=True): + state = event.data["new_state"] + derivative = round(float(state.state), config["sensor"]["round"]) - last_derivative = derivative + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: From 7d895653fbe2b7681391bdfe717514139f500751 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 18:19:56 +0200 Subject: [PATCH 0480/1118] Bump reolink-aio to 0.14.3 (#149191) --- homeassistant/components/reolink/diagnostics.py | 2 +- homeassistant/components/reolink/entity.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/sensor.py | 13 ++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- tests/components/reolink/test_media_source.py | 1 + tests/components/reolink/test_sensor.py | 2 +- 9 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index c5085c9ca18..d940bda2680 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -42,7 +42,7 @@ async def async_get_config_entry_diagnostics( "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, "WiFi connection": api.wifi_connection, - "WiFi signal": api.wifi_signal, + "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, "RTSP enabled": api.rtsp_enabled, "ONVIF enabled": api.onvif_enabled, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index a83dc259e1b..971b7ec4be1 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -167,7 +167,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data, coordinator) self._channel = channel - if self._host.api.supported(channel, "UID"): + if self._host.api.is_nvr and self._host.api.supported(channel, "UID"): self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" else: self._attr_unique_id = ( diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index c422af292b9..f8b8191a851 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.2"] + "requirements": ["reolink-aio==0.14.3"] } diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 85de03dd1a3..d63655d1173 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -16,7 +16,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -123,12 +128,14 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", - cmd_key="GetWifiSignal", + cmd_key="115", translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, - value=lambda api: api.wifi_signal, + value=lambda api: api.wifi_signal(), supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, ), ReolinkHostSensorEntityDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 513422df915..074b68773da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.2 +reolink-aio==0.14.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fac0aba573..10be4658356 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2209,7 +2209,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.2 +reolink-aio==0.14.3 # homeassistant.components.rflink rflink==0.0.67 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1ca6bb4eb55..a3e28f49194 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,7 +123,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 host_mock.wifi_connection = False - host_mock.wifi_signal = None + host_mock.wifi_signal.return_value = None host_mock.whiteled_mode_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 67ae78e5fa4..31da3b213be 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -284,6 +284,7 @@ async def test_browsing_h265_encoding( ) -> None: """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id + reolink_connect.is_nvr = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index df164634355..3a120889a98 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -22,7 +22,7 @@ async def test_sensors( """Test sensor entities.""" reolink_connect.ptz_pan_position.return_value = 1200 reolink_connect.wifi_connection = True - reolink_connect.wifi_signal = 3 + reolink_connect.wifi_signal.return_value = 3 reolink_connect.hdd_list = [0] reolink_connect.hdd_storage.return_value = 95 From 941d3c2be469014571cca4ace312370f52be4fa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 18:23:58 +0200 Subject: [PATCH 0481/1118] Improve integration sensor tests (#149180) --- tests/components/integration/test_sensor.py | 186 ++++++++++++++------ 1 file changed, 135 insertions(+), 51 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 3d5549d88bf..bda0cefb572 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -21,12 +21,19 @@ from homeassistant.const import ( UnitOfTime, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import ( condition, device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -297,24 +304,32 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ - # time, value, attributes, expected + # time, value, attributes ( - (0, 0, {}, 0), - (20, 10, {}, 1.67), - (30, 30, {}, 5.0), - (40, 5, {}, 7.92), - (50, 5, {}, 8.75), # This fires a state report - (60, 0, {}, 9.17), + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), ), ( - (0, 0, {}, 0), - (20, 10, {}, 1.67), - (30, 30, {}, 5.0), - (40, 5, {}, 7.92), - (50, 5, {"foo": "bar"}, 8.75), # This fires a state change - (60, 0, {}, 9.17), + ( + (0, 0, {}), + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (0, 1.67, 5.0, 7.92, 8.75, 9.58, 10.0), ), ], ) @@ -323,8 +338,17 @@ async def test_trapezoidal( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -349,7 +373,7 @@ async def test_trapezoidal( start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -357,32 +381,45 @@ async def test_trapezoidal( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = [events[0].data["new_state"].state] + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[1:] + ] + assert states == ["unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ - # time, value, attributes, expected - ( - (20, 10, {}, 0.0), - (30, 30, {}, 1.67), - (40, 5, {}, 6.67), - (50, 5, {}, 7.5), # This fires a state report - (60, 0, {}, 8.33), + ( # time, value, attributes, expected + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), ), ( - (20, 10, {}, 0.0), - (30, 30, {}, 1.67), - (40, 5, {}, 6.67), - (50, 5, {"foo": "bar"}, 7.5), # This fires a state change - (60, 0, {}, 8.33), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), + (60, 5, {"foo": "baz"}), + (70, 0, {}), + ), + (0, 1.67, 6.67, 7.5, 8.33, 9.17), ), ], ) @@ -391,8 +428,17 @@ async def test_left( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state with left Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -420,7 +466,7 @@ async def test_left( # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -428,31 +474,50 @@ async def test_left( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR @pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( - "sequence", + ("sequence", "expected_states"), [ + # time, value, attributes, expected ( - (20, 10, {}, 3.33), - (30, 30, {}, 8.33), - (40, 5, {}, 9.17), - (50, 5, {}, 10.0), # This fires a state report - (60, 0, {}, 10.0), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {}), # This fires a state report + (60, 5, {}), # This fires a state report + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), ), ( - (20, 10, {}, 3.33), - (30, 30, {}, 8.33), - (40, 5, {}, 9.17), - (50, 5, {"foo": "bar"}, 10.0), # This fires a state change - (60, 0, {}, 10.0), + ( + (20, 10, {}), + (30, 30, {}), + (40, 5, {}), + (50, 5, {"foo": "bar"}), # This fires a state change + (60, 5, {"foo": "baz"}), # This fires a state change + (70, 0, {}), + ), + (3.33, 8.33, 9.17, 10.0, 10.83), ), ], ) @@ -461,8 +526,17 @@ async def test_right( sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, extra_config: dict[str, Any], + expected_states: tuple[float, ...], ) -> None: """Test integration sensor state with right Riemann method.""" + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event(hass, "sensor.integration", _capture_event) + config = { "sensor": { "platform": "integration", @@ -490,7 +564,7 @@ async def test_right( # Testing a power sensor with non-monotonic intervals and values start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: - for time, value, extra_attributes, expected in sequence: + for time, value, extra_attributes in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -498,10 +572,20 @@ async def test_right( {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) - await hass.async_block_till_done() - state = hass.states.get("sensor.integration") - assert round(float(state.state), config["sensor"]["round"]) == expected + await hass.async_block_till_done() + await hass.async_block_till_done() + states = ( + [events[0].data["new_state"].state] + + [events[1].data["new_state"].state] + + [ + round(float(event.data["new_state"].state), config["sensor"]["round"]) + for event in events[2:] + ] + ) + assert states == ["unknown", "unknown", *expected_states] + + state = events[-1].data["new_state"] assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR From b6014da1212f18f2a90d847c912e3774ba7d59c9 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 21:35:28 +0200 Subject: [PATCH 0482/1118] Add Reolink wifi signal sensor for IPC cams (#149200) --- homeassistant/components/reolink/diagnostics.py | 2 ++ homeassistant/components/reolink/sensor.py | 12 ++++++++++++ tests/components/reolink/conftest.py | 2 +- .../reolink/snapshots/test_diagnostics.ambr | 3 ++- tests/components/reolink/test_sensor.py | 4 ++-- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index d940bda2680..48f6b709c23 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -24,6 +24,8 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + if (signal := api.wifi_signal(ch)) is not None: + IPC_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} for chime in api.chime_list: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index d63655d1173..cd03f2b59b5 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -123,6 +123,18 @@ SENSORS = ( value=lambda api, ch: api.baichuan.day_night_state(ch), supported=lambda api, ch: api.supported(ch, "day_night_state"), ), + ReolinkSensorEntityDescription( + key="wifi_signal", + cmd_key="115", + translation_key="wifi_signal", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + value=lambda api, ch: api.wifi_signal(ch), + supported=lambda api, ch: api.supported(ch, "wifi"), + ), ) HOST_SENSORS = ( diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a3e28f49194..4e2179dcd2c 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,7 +123,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 host_mock.wifi_connection = False - host_mock.wifi_signal.return_value = None + host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index a6d7f14a149..25a9dc299aa 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -28,6 +28,7 @@ 'HTTPS': True, 'IPC cams': dict({ '0': dict({ + 'WiFi signal': -45, 'encoding main': 'h264', 'firmware version': 'v1.1.0.0.0.0000', 'hardware version': 'IPC_00001', @@ -38,7 +39,7 @@ 'RTMP enabled': True, 'RTSP enabled': True, 'WiFi connection': False, - 'WiFi signal': None, + 'WiFi signal': -45, 'abilities': dict({ 'abilityChn': list([ dict({ diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 3a120889a98..c3fe8d89951 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -22,7 +22,7 @@ async def test_sensors( """Test sensor entities.""" reolink_connect.ptz_pan_position.return_value = 1200 reolink_connect.wifi_connection = True - reolink_connect.wifi_signal.return_value = 3 + reolink_connect.wifi_signal.return_value = -55 reolink_connect.hdd_list = [0] reolink_connect.hdd_storage.return_value = 95 @@ -35,7 +35,7 @@ async def test_sensors( assert hass.states.get(entity_id).state == "1200" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" - assert hass.states.get(entity_id).state == "3" + assert hass.states.get(entity_id).state == "-55" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" assert hass.states.get(entity_id).state == "95" From ecb6cc50b9af62d2fe43b6e13888a9e14476183f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 21 Jul 2025 22:48:02 +0200 Subject: [PATCH 0483/1118] Add Reolink post recording time select entity (#149201) Co-authored-by: Norbert Rittel --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/select.py | 11 +++++++++++ homeassistant/components/reolink/strings.json | 3 +++ tests/components/reolink/conftest.py | 1 + 4 files changed, 18 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 875af48e47c..0c9831af2a8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -389,6 +389,9 @@ }, "packing_time": { "default": "mdi:record-rec" + }, + "post_rec_time": { + "default": "mdi:record-rec" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 2ee2b790687..d55cf9386f9 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -250,6 +250,17 @@ SELECT_ENTITIES = ( value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), + ReolinkSelectEntityDescription( + key="post_rec_time", + cmd_key="GetRec", + translation_key="post_rec_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + get_options=lambda api, ch: api.post_recording_time_list(ch), + supported=lambda api, ch: api.supported(ch, "post_rec_time"), + value=lambda api, ch: api.post_recording_time(ch), + method=lambda api, ch, value: api.set_post_recording_time(ch, value), + ), ) HOST_SELECT_ENTITIES = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5473887a8ff..1b155af6a4d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -857,6 +857,9 @@ }, "packing_time": { "name": "Recording packing time" + }, + "post_rec_time": { + "name": "Post-recording time" } }, "sensor": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 4e2179dcd2c..a5f528edef6 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -125,6 +125,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.wifi_connection = False host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] + host_mock.post_recording_time_list.return_value = [] host_mock.zoom_range.return_value = { "zoom": {"pos": {"min": 0, "max": 100}}, "focus": {"pos": {"min": 0, "max": 100}}, From 79dd91ebc61afbc4cb65d6b4162880615bca8850 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Mon, 21 Jul 2025 22:52:24 +0200 Subject: [PATCH 0484/1118] Add sauna light control in Huum (#149169) --- homeassistant/components/huum/const.py | 6 +- homeassistant/components/huum/light.py | 62 +++++++++++++++ tests/components/huum/conftest.py | 6 ++ .../components/huum/snapshots/test_light.ambr | 58 ++++++++++++++ tests/components/huum/test_light.py | 76 +++++++++++++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huum/light.py create mode 100644 tests/components/huum/snapshots/test_light.ambr create mode 100644 tests/components/huum/test_light.py diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 6691a2ad8b3..13663d31cd0 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,8 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT] + +CONFIG_STEAMER = 1 +CONFIG_LIGHT = 2 +CONFIG_STEAMER_AND_LIGHT = 3 diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py new file mode 100644 index 00000000000..8eb35afdda2 --- /dev/null +++ b/homeassistant/components/huum/light.py @@ -0,0 +1,62 @@ +"""Control for light.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light if applicable.""" + coordinator = config_entry.runtime_data + + # Light is configured for this sauna. + if coordinator.data.config in [CONFIG_LIGHT, CONFIG_STEAMER_AND_LIGHT]: + async_add_entities([HuumLight(coordinator)]) + + +class HuumLight(HuumBaseEntity, LightEntity): + """Representation of a light.""" + + _attr_name = "Light" + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the light.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def is_on(self) -> bool | None: + """Return the current light status.""" + return self.coordinator.data.light == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + if not self.is_on: + await self._toggle_light() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + if self.is_on: + await self._toggle_light() + + async def _toggle_light(self) -> None: + await self.coordinator.huum.toggle_light() + await self.coordinator.async_refresh() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py index 023abd4429e..8342603a30d 100644 --- a/tests/components/huum/conftest.py +++ b/tests/components/huum/conftest.py @@ -29,8 +29,13 @@ def mock_huum() -> Generator[AsyncMock]: "homeassistant.components.huum.coordinator.Huum.turn_on", return_value=huum, ) as turn_on, + patch( + "homeassistant.components.huum.coordinator.Huum.toggle_light", + return_value=huum, + ) as toggle_light, ): huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.config = 3 huum.door_closed = True huum.temperature = 30 huum.sauna_name = 123456 @@ -45,6 +50,7 @@ def mock_huum() -> Generator[AsyncMock]: huum.sauna_config.max_timer = 0 huum.sauna_config.min_timer = 0 huum.turn_on = turn_on + huum.toggle_light = toggle_light yield huum diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr new file mode 100644 index 00000000000..918210272b2 --- /dev/null +++ b/tests/components/huum/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_light[light.huum_sauna_light-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.huum_sauna_light', + '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': 'Light', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_light[light.huum_sauna_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Huum sauna Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.huum_sauna_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/huum/test_light.py b/tests/components/huum/test_light.py new file mode 100644 index 00000000000..8ad12a36f4e --- /dev/null +++ b/tests/components/huum/test_light.py @@ -0,0 +1,76 @@ +"""Tests for the Huum light entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "light.huum_sauna_light" + + +async def test_light( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off light.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() + + +async def test_light_turn_on( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning on light.""" + mock_huum.light = 0 + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.LIGHT]) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + Platform.LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_huum.toggle_light.assert_called_once() From ef2531d28da435f43fdba0bece6a44ac9604fbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 21 Jul 2025 20:52:48 +0000 Subject: [PATCH 0485/1118] Add diagnostics support to Huawei LTE (#131085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: abmantis Co-authored-by: Abílio Costa --- .../components/huawei_lte/diagnostics.py | 86 +++++ tests/components/huawei_lte/__init__.py | 317 ++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 201 +++++++++++ .../components/huawei_lte/test_diagnostics.py | 38 +++ 4 files changed, 642 insertions(+) create mode 100644 homeassistant/components/huawei_lte/diagnostics.py create mode 100644 tests/components/huawei_lte/snapshots/test_diagnostics.ambr create mode 100644 tests/components/huawei_lte/test_diagnostics.py diff --git a/homeassistant/components/huawei_lte/diagnostics.py b/homeassistant/components/huawei_lte/diagnostics.py new file mode 100644 index 00000000000..975ab476e6c --- /dev/null +++ b/homeassistant/components/huawei_lte/diagnostics.py @@ -0,0 +1,86 @@ +"""Diagnostics support for Huawei LTE.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +ENTRY_FIELDS_DATA_TO_REDACT = { + "mac", + "username", + "password", +} +DEVICE_INFORMATION_DATA_TO_REDACT = { + "SerialNumber", + "Imei", + "Imsi", + "Iccid", + "Msisdn", + "MacAddress1", + "MacAddress2", + "WanIPAddress", + "wan_dns_address", + "WanIPv6Address", + "wan_ipv6_dns_address", + "Mccmnc", + "WifiMacAddrWl0", + "WifiMacAddrWl1", +} +DEVICE_SIGNAL_DATA_TO_REDACT = { + "pci", + "cell_id", + "enodeb_id", + "rac", + "lac", + "tac", + "nei_cellid", + "plmn", + "bsic", +} +MONITORING_STATUS_DATA_TO_REDACT = { + "PrimaryDns", + "SecondaryDns", + "PrimaryIPv6Dns", + "SecondaryIPv6Dns", +} +NET_CURRENT_PLMN_DATA_TO_REDACT = { + "net_current_plmn", +} +LAN_HOST_INFO_DATA_TO_REDACT = { + "lan_host_info", +} +WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT = { + "Ssid", + "WifiSsid", +} +WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT = { + "WifiMac", +} +TO_REDACT = { + *ENTRY_FIELDS_DATA_TO_REDACT, + *DEVICE_INFORMATION_DATA_TO_REDACT, + *DEVICE_SIGNAL_DATA_TO_REDACT, + *MONITORING_STATUS_DATA_TO_REDACT, + *NET_CURRENT_PLMN_DATA_TO_REDACT, + *LAN_HOST_INFO_DATA_TO_REDACT, + *WLAN_WIFI_GUEST_NETWORK_SWITCH_DATA_TO_REDACT, + *WLAN_MULTI_BASIC_SETTINGS_DATA_TO_REDACT, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "entry": entry.data, + "router": hass.data[DOMAIN].routers[entry.entry_id].data, + }, + TO_REDACT, + ) diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py index 2d43a5eade1..f9f16a2473c 100644 --- a/tests/components/huawei_lte/__init__.py +++ b/tests/components/huawei_lte/__init__.py @@ -21,3 +21,320 @@ def magic_client(multi_basic_settings_value: dict) -> MagicMock: wifi_feature_switch=wifi_feature_switch, ) return MagicMock(device=device, monitoring=monitoring, wlan=wlan) + + +def magic_client_full() -> MagicMock: + """Extended mock for huawei_lte.Client with all API methods.""" + information = MagicMock( + return_value={ + "DeviceName": "Test Router", + "SerialNumber": "test-serial-number", + "Imei": "123456789012345", + "Imsi": "123451234567890", + "Iccid": "12345678901234567890", + "Msisdn": None, + "HardwareVersion": "1.0.0", + "SoftwareVersion": "2.0.0", + "WebUIVersion": "3.0.0", + "MacAddress1": "22:22:33:44:55:66", + "MacAddress2": None, + "WanIPAddress": "23.215.0.138", + "wan_dns_address": "8.8.8.8", + "WanIPv6Address": "2600:1406:3a00:21::173e:2e66", + "wan_ipv6_dns_address": "2001:4860:4860:0:0:0:0:8888", + "ProductFamily": "LTE", + "Classify": "cpe", + "supportmode": "LTE|WCDMA|GSM", + "workmode": "LTE", + "submask": "255.255.255.255", + "Mccmnc": "20499", + "iniversion": "test-ini-version", + "uptime": "4242424", + "ImeiSvn": "01", + "WifiMacAddrWl0": "22:22:33:44:55:77", + "WifiMacAddrWl1": "22:22:33:44:55:88", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + basic_information = MagicMock( + return_value={ + "classify": "cpe", + "devicename": "Test Router", + "multimode": "0", + "productfamily": "LTE", + "restore_default_status": "0", + "sim_save_pin_enable": "1", + "spreadname_en": "Huawei 4G Router N123", + "spreadname_zh": "\u534e\u4e3a4G\u8def\u7531 N123", + } + ) + signal = MagicMock( + return_value={ + "pci": "123", + "sc": None, + "cell_id": "12345678", + "rssi": "-70dBm", + "rsrp": "-100dBm", + "rsrq": "-10.0dB", + "sinr": "10dB", + "rscp": None, + "ecio": None, + "mode": "7", + "ulbandwidth": "20MHz", + "dlbandwidth": "20MHz", + "txpower": "PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm", + "tdd": None, + "ul_mcs": "mcsUpCarrier1:20", + "dl_mcs": "mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9", + "earfcn": "DL:123 UL:45678", + "rrc_status": "1", + "rac": None, + "lac": None, + "tac": "12345", + "band": "1", + "nei_cellid": "23456789", + "plmn": "20499", + "ims": "0", + "wdlfreq": None, + "lteulfreq": "19697", + "ltedlfreq": "21597", + "transmode": "TM[4]", + "enodeb_id": "0012345", + "cqi0": "11", + "cqi1": "5", + "ulfrequency": "1969700kHz", + "dlfrequency": "2159700kHz", + "arfcn": None, + "bsic": None, + "rxlev": None, + } + ) + + check_notifications = MagicMock( + return_value={ + "UnreadMessage": "2", + "SmsStorageFull": "0", + "OnlineUpdateStatus": "42", + "SimOperEvent": "0", + } + ) + status = MagicMock( + return_value={ + "ConnectionStatus": "901", + "WifiConnectionStatus": None, + "SignalStrength": None, + "SignalIcon": "5", + "CurrentNetworkType": "19", + "CurrentServiceDomain": "3", + "RoamingStatus": "0", + "BatteryStatus": None, + "BatteryLevel": None, + "BatteryPercent": None, + "simlockStatus": "0", + "PrimaryDns": "8.8.8.8", + "SecondaryDns": "8.8.4.4", + "wififrequence": "1", + "flymode": "0", + "PrimaryIPv6Dns": "2001:4860:4860:0:0:0:0:8888", + "SecondaryIPv6Dns": "2001:4860:4860:0:0:0:0:8844", + "CurrentWifiUser": "42", + "TotalWifiUser": "64", + "currenttotalwifiuser": "0", + "ServiceStatus": "2", + "SimStatus": "1", + "WifiStatus": "1", + "CurrentNetworkTypeEx": "101", + "maxsignal": "5", + "wifiindooronly": "0", + "cellroam": "1", + "classify": "cpe", + "usbup": "0", + "wifiswitchstatus": "1", + "WifiStatusExCustom": "0", + "hvdcp_online": "0", + } + ) + month_statistics = MagicMock( + return_value={ + "CurrentMonthDownload": "1000000000", + "CurrentMonthUpload": "500000000", + "MonthDuration": "720000", + "MonthLastClearTime": "2025-07-01", + "CurrentDayUsed": "123456789", + "CurrentDayDuration": "10000", + } + ) + traffic_statistics = MagicMock( + return_value={ + "CurrentConnectTime": "123456", + "CurrentUpload": "2000000000", + "CurrentDownload": "5000000000", + "CurrentDownloadRate": "700", + "CurrentUploadRate": "600", + "TotalUpload": "20000000000", + "TotalDownload": "50000000000", + "TotalConnectTime": "1234567", + "showtraffic": "1", + } + ) + + current_plmn = MagicMock( + return_value={ + "State": "1", + "FullName": "Test Network", + "ShortName": "Test", + "Numeric": "12345", + } + ) + net_mode = MagicMock( + return_value={ + "NetworkMode": "03", + "NetworkBand": "3FFFFFFF", + "LTEBand": "7FFFFFFFFFFFFFFF", + } + ) + + sms_count = MagicMock( + return_value={ + "LocalUnread": "0", + "LocalInbox": "5", + "LocalOutbox": "2", + "LocalDraft": "1", + "LocalDeleted": "0", + "SimUnread": "0", + "SimInbox": "0", + "SimOutbox": "0", + "SimDraft": "0", + "LocalMax": "500", + "SimMax": "30", + "SimUsed": "0", + "NewMsg": "0", + } + ) + + mobile_dataswitch = MagicMock(return_value={"dataswitch": "1"}) + + lan_host_info = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "Active": "0", + "ActualName": "TestDevice1", + "AddressSource": "DHCP", + "AssociatedSsid": None, + "AssociatedTime": None, + "HostName": "TestDevice1", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.9.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.100", + "LeaseTime": "2204542", + "MacAddress": "AA:BB:CC:DD:EE:FF", + "isLocalDevice": "0", + }, + { + "Active": "1", + "ActualName": "TestDevice2", + "AddressSource": "DHCP", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "InterfaceType": "Wireless", + "IpAddress": "192.168.1.101", + "LeaseTime": "552115", + "MacAddress": "11:22:33:44:55:66", + "isLocalDevice": "0", + }, + ] + } + } + ) + wlan_host_list = MagicMock( + return_value={ + "Hosts": { + "Host": [ + { + "ActualName": "TestDevice2", + "AssociatedSsid": "TestSSID", + "AssociatedTime": "258632", + "Frequency": "2.4GHz", + "HostName": "TestDevice2", + "ID": "InternetGatewayDevice.LANDevice.1.Hosts.Host.17.", + "IpAddress": "192.168.1.101;fe80::b222:33ff:fe44:5566", + "MacAddress": "11:22:33:44:55:66", + } + ] + } + } + ) + multi_basic_settings = MagicMock( + return_value={"Ssid": [{"wifiisguestnetwork": "1", "WifiEnable": "0"}]} + ) + wifi_feature_switch = MagicMock( + return_value={ + "wifi_dbdc_enable": "0", + "acmode_enable": "1", + "wifiautocountry_enabled": "0", + "wps_cancel_enable": "1", + "wifimacfilterextendenable": "1", + "wifimaxmacfilternum": "32", + "paraimmediatework_enable": "1", + "guestwifi_enable": "0", + "wifi5gnamepostfix": "_5G", + "wifiguesttimeextendenable": "1", + "chinesessid_enable": "0", + "isdoublechip": "1", + "opennonewps_enable": "1", + "wifi_country_enable": "0", + "wifi5g_enabled": "1", + "wifiwpsmode": "0", + "pmf_enable": "1", + "support_trigger_dualband_wps": "1", + "maxapnum": "4", + "wifi_chip_maxassoc": "32", + "wifiwpssuportwepnone": "0", + "maxassocoffloadon": None, + "guidefrequencyenable": "0", + "showssid_enable": "0", + "wifishowradioswitch": "3", + "wifispecialcharenable": "1", + "wifi24g_switch_enable": "1", + "wifi_dfs_enable": "0", + "show_maxassoc": "0", + "hilink_dbho_enable": "1", + "oledshowpassword": "1", + "doubleap5g_enable": "0", + "wps_switch_enable": "1", + } + ) + + device = MagicMock( + information=information, basic_information=basic_information, signal=signal + ) + monitoring = MagicMock( + check_notifications=check_notifications, + status=status, + month_statistics=month_statistics, + traffic_statistics=traffic_statistics, + ) + net = MagicMock(current_plmn=current_plmn, net_mode=net_mode) + sms = MagicMock(sms_count=sms_count) + dial_up = MagicMock(mobile_dataswitch=mobile_dataswitch) + lan = MagicMock(host_info=lan_host_info) + wlan = MagicMock( + multi_basic_settings=multi_basic_settings, + wifi_feature_switch=wifi_feature_switch, + host_list=wlan_host_list, + ) + + return MagicMock( + device=device, + monitoring=monitoring, + net=net, + sms=sms, + dial_up=dial_up, + lan=lan, + wlan=wlan, + ) diff --git a/tests/components/huawei_lte/snapshots/test_diagnostics.ambr b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..0c2076d9c63 --- /dev/null +++ b/tests/components/huawei_lte/snapshots/test_diagnostics.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'mac': '**REDACTED**', + 'url': 'http://huawei-lte.example.com', + }), + 'router': dict({ + 'device_information': dict({ + 'Classify': 'cpe', + 'DeviceName': 'Test Router', + 'HardwareVersion': '1.0.0', + 'Iccid': '**REDACTED**', + 'Imei': '**REDACTED**', + 'ImeiSvn': '01', + 'Imsi': '**REDACTED**', + 'MacAddress1': '**REDACTED**', + 'MacAddress2': None, + 'Mccmnc': '**REDACTED**', + 'Msisdn': None, + 'ProductFamily': 'LTE', + 'SerialNumber': '**REDACTED**', + 'SoftwareVersion': '2.0.0', + 'WanIPAddress': '**REDACTED**', + 'WanIPv6Address': '**REDACTED**', + 'WebUIVersion': '3.0.0', + 'WifiMacAddrWl0': '**REDACTED**', + 'WifiMacAddrWl1': '**REDACTED**', + 'iniversion': 'test-ini-version', + 'spreadname_en': 'Huawei 4G Router N123', + 'spreadname_zh': '华为4G路由 N123', + 'submask': '255.255.255.255', + 'supportmode': 'LTE|WCDMA|GSM', + 'uptime': '4242424', + 'wan_dns_address': '**REDACTED**', + 'wan_ipv6_dns_address': '**REDACTED**', + 'workmode': 'LTE', + }), + 'device_signal': dict({ + 'arfcn': None, + 'band': '1', + 'bsic': None, + 'cell_id': '**REDACTED**', + 'cqi0': '11', + 'cqi1': '5', + 'dl_mcs': 'mcsDownCarrier1Code0:8 mcsDownCarrier1Code1:9', + 'dlbandwidth': '20MHz', + 'dlfrequency': '2159700kHz', + 'earfcn': 'DL:123 UL:45678', + 'ecio': None, + 'enodeb_id': '**REDACTED**', + 'ims': '0', + 'lac': None, + 'ltedlfreq': '21597', + 'lteulfreq': '19697', + 'mode': '7', + 'nei_cellid': '**REDACTED**', + 'pci': '**REDACTED**', + 'plmn': '**REDACTED**', + 'rac': None, + 'rrc_status': '1', + 'rscp': None, + 'rsrp': '-100dBm', + 'rsrq': '-10.0dB', + 'rssi': '-70dBm', + 'rxlev': None, + 'sc': None, + 'sinr': '10dB', + 'tac': '**REDACTED**', + 'tdd': None, + 'transmode': 'TM[4]', + 'txpower': 'PPusch:-1dBm PPucch:-11dBm PSrs:10dBm PPrach:0dBm', + 'ul_mcs': 'mcsUpCarrier1:20', + 'ulbandwidth': '20MHz', + 'ulfrequency': '1969700kHz', + 'wdlfreq': None, + }), + 'dialup_mobile_dataswitch': dict({ + 'dataswitch': '1', + }), + 'lan_host_info': '**REDACTED**', + 'monitoring_check_notifications': dict({ + 'OnlineUpdateStatus': '42', + 'SimOperEvent': '0', + 'SmsStorageFull': '0', + 'UnreadMessage': '2', + }), + 'monitoring_month_statistics': dict({ + 'CurrentDayDuration': '10000', + 'CurrentDayUsed': '123456789', + 'CurrentMonthDownload': '1000000000', + 'CurrentMonthUpload': '500000000', + 'MonthDuration': '720000', + 'MonthLastClearTime': '2025-07-01', + }), + 'monitoring_status': dict({ + 'BatteryLevel': None, + 'BatteryPercent': None, + 'BatteryStatus': None, + 'ConnectionStatus': '901', + 'CurrentNetworkType': '19', + 'CurrentNetworkTypeEx': '101', + 'CurrentServiceDomain': '3', + 'CurrentWifiUser': '42', + 'PrimaryDns': '**REDACTED**', + 'PrimaryIPv6Dns': '**REDACTED**', + 'RoamingStatus': '0', + 'SecondaryDns': '**REDACTED**', + 'SecondaryIPv6Dns': '**REDACTED**', + 'ServiceStatus': '2', + 'SignalIcon': '5', + 'SignalStrength': None, + 'SimStatus': '1', + 'TotalWifiUser': '64', + 'WifiConnectionStatus': None, + 'WifiStatus': '1', + 'WifiStatusExCustom': '0', + 'cellroam': '1', + 'classify': 'cpe', + 'currenttotalwifiuser': '0', + 'flymode': '0', + 'hvdcp_online': '0', + 'maxsignal': '5', + 'simlockStatus': '0', + 'usbup': '0', + 'wififrequence': '1', + 'wifiindooronly': '0', + 'wifiswitchstatus': '1', + }), + 'monitoring_traffic_statistics': dict({ + 'CurrentConnectTime': '123456', + 'CurrentDownload': '5000000000', + 'CurrentDownloadRate': '700', + 'CurrentUpload': '2000000000', + 'CurrentUploadRate': '600', + 'TotalConnectTime': '1234567', + 'TotalDownload': '50000000000', + 'TotalUpload': '20000000000', + 'showtraffic': '1', + }), + 'net_current_plmn': '**REDACTED**', + 'net_net_mode': dict({ + 'LTEBand': '7FFFFFFFFFFFFFFF', + 'NetworkBand': '3FFFFFFF', + 'NetworkMode': '03', + }), + 'sms_sms_count': dict({ + 'LocalDeleted': '0', + 'LocalDraft': '1', + 'LocalInbox': '5', + 'LocalMax': '500', + 'LocalOutbox': '2', + 'LocalUnread': '0', + 'NewMsg': '0', + 'SimDraft': '0', + 'SimInbox': '0', + 'SimMax': '30', + 'SimOutbox': '0', + 'SimUnread': '0', + 'SimUsed': '0', + }), + 'wlan_wifi_feature_switch': dict({ + 'acmode_enable': '1', + 'chinesessid_enable': '0', + 'doubleap5g_enable': '0', + 'guestwifi_enable': '0', + 'guidefrequencyenable': '0', + 'hilink_dbho_enable': '1', + 'isdoublechip': '1', + 'maxapnum': '4', + 'maxassocoffloadon': None, + 'oledshowpassword': '1', + 'opennonewps_enable': '1', + 'paraimmediatework_enable': '1', + 'pmf_enable': '1', + 'show_maxassoc': '0', + 'showssid_enable': '0', + 'support_trigger_dualband_wps': '1', + 'wifi24g_switch_enable': '1', + 'wifi5g_enabled': '1', + 'wifi5gnamepostfix': '_5G', + 'wifi_chip_maxassoc': '32', + 'wifi_country_enable': '0', + 'wifi_dbdc_enable': '0', + 'wifi_dfs_enable': '0', + 'wifiautocountry_enabled': '0', + 'wifiguesttimeextendenable': '1', + 'wifimacfilterextendenable': '1', + 'wifimaxmacfilternum': '32', + 'wifishowradioswitch': '3', + 'wifispecialcharenable': '1', + 'wifiwpsmode': '0', + 'wifiwpssuportwepnone': '0', + 'wps_cancel_enable': '1', + 'wps_switch_enable': '1', + }), + 'wlan_wifi_guest_network_switch': dict({ + }), + }), + }) +# --- diff --git a/tests/components/huawei_lte/test_diagnostics.py b/tests/components/huawei_lte/test_diagnostics.py new file mode 100644 index 00000000000..e63ba94e9be --- /dev/null +++ b/tests/components/huawei_lte/test_diagnostics.py @@ -0,0 +1,38 @@ +"""Test huawei_lte diagnostics.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client_full + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_entry_diagnostics( + client, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + client.return_value = magic_client_full() + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, huawei_lte) + + assert result == snapshot(exclude=props("entry_id", "created_at", "modified_at")) From 42cf4e8db755fb43c3f8ffce14e8332e71f8006c Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 22 Jul 2025 13:42:40 +0800 Subject: [PATCH 0486/1118] Fix multiple webhook secrets for Telegram bot (#149103) --- homeassistant/components/telegram_bot/webhooks.py | 14 ++++++++++---- tests/components/telegram_bot/test_telegram_bot.py | 12 ++++++------ tests/components/telegram_bot/test_webhooks.py | 5 +++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 0bfad34681a..29c3305858b 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -82,7 +82,7 @@ class PushBot(BaseTelegramBot): self.base_url = config.data.get(CONF_URL) or get_url( hass, require_ssl=True, allow_internal=False ) - self.webhook_url = f"{self.base_url}{TELEGRAM_WEBHOOK_URL}" + self.webhook_url = self.base_url + _get_webhook_url(bot) async def shutdown(self) -> None: """Shutdown the app.""" @@ -98,9 +98,11 @@ class PushBot(BaseTelegramBot): api_kwargs={"secret_token": self.secret_token}, connect_timeout=5, ) - except TelegramError: + except TelegramError as err: retry_num += 1 - _LOGGER.warning("Error trying to set webhook (retry #%d)", retry_num) + _LOGGER.warning( + "Error trying to set webhook (retry #%d)", retry_num, exc_info=err + ) return False @@ -143,7 +145,6 @@ class PushBotView(HomeAssistantView): """View for handling webhook calls from Telegram.""" requires_auth = False - url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" def __init__( @@ -160,6 +161,7 @@ class PushBotView(HomeAssistantView): self.application = application self.trusted_networks = trusted_networks self.secret_token = secret_token + self.url = _get_webhook_url(bot) async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" @@ -183,3 +185,7 @@ class PushBotView(HomeAssistantView): await self.application.process_update(update) return None + + +def _get_webhook_url(bot: Bot) -> str: + return f"{TELEGRAM_WEBHOOK_URL}_{bot.id}" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 73dd9e27763..80b9859ceab 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -364,7 +364,7 @@ async def test_webhook_endpoint_generates_telegram_text_event( events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -391,7 +391,7 @@ async def test_webhook_endpoint_generates_telegram_command_event( events = async_capture_events(hass, "telegram_command") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_command, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -418,7 +418,7 @@ async def test_webhook_endpoint_generates_telegram_callback_event( events = async_capture_events(hass, "telegram_callback") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_callback_query, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -594,7 +594,7 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=unauthorized_update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -618,7 +618,7 @@ async def test_webhook_endpoint_without_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, ) assert response.status == 401 @@ -636,7 +636,7 @@ async def test_webhook_endpoint_invalid_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, ) diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 3419d33074d..a02bb3e3358 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from telegram import WebhookInfo from telegram.error import TimedOut +from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -115,7 +116,7 @@ async def test_webhooks_update_invalid_json( client = await hass_client() response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 400 @@ -139,7 +140,7 @@ async def test_webhooks_unauthorized_network( return_value=IPv4Network("1.2.3.4"), ) as mock_remote: response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", json="mock json", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) From 48b8827390502fa992e1bb28e74369bc82f5fe8d Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Tue, 22 Jul 2025 02:56:54 -0400 Subject: [PATCH 0487/1118] Bump asyncsleepiq to 1.5.3 (#149215) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index db29e5ab586..5082e2313df 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.5.2"] + "requirements": ["asyncsleepiq==1.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 074b68773da..710a7296850 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -542,7 +542,7 @@ asyncinotify==4.2.0 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.5.3 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10be4658356..c224132c2d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -500,7 +500,7 @@ async-upnp-client==0.45.0 asyncarve==0.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.5.2 +asyncsleepiq==1.5.3 # homeassistant.components.aurora auroranoaa==0.0.5 From 3e7974a63864f7ec37a360562e9810eece04e605 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 22 Jul 2025 08:58:32 +0200 Subject: [PATCH 0488/1118] Add missing hyphen to "post-processing" in `nzbget` (#149205) --- homeassistant/components/nzbget/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index 3b41e798d22..358be131c93 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -43,10 +43,10 @@ "name": "Disk free" }, "post_processing_jobs": { - "name": "Post processing jobs" + "name": "Post-processing jobs" }, "post_processing_paused": { - "name": "Post processing paused" + "name": "Post-processing paused" }, "queue_size": { "name": "Queue size" From df4e1411cc8041a3e7e9d91c3216df60568f4e40 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:00:25 +0200 Subject: [PATCH 0489/1118] Bump uiprotect to version 7.18.1 (#149209) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e5b017e0ab6..5beb4ca059d 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.16.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.18.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 710a7296850..47e753be1f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.16.0 +uiprotect==7.18.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c224132c2d7..d74eb8270b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.16.0 +uiprotect==7.18.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 2315bcbfe3cff9f838113351f98adf9b124a16b3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:02:15 +0200 Subject: [PATCH 0490/1118] Set has_entity_name in Onkyo (#149223) --- homeassistant/components/onkyo/media_player.py | 1 + homeassistant/components/onkyo/quality_scale.yaml | 2 +- tests/components/onkyo/snapshots/test_media_player.ambr | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 2965388236d..05374bfe6cf 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -152,6 +152,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False + _attr_has_entity_name = True _supports_volume: bool = False # None means no technical possibility of support diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index caf0d33fafc..1e8bf07e66a 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -22,7 +22,7 @@ rules: comment: | Currently we store created entities in hass.data. That should be removed in the future. entity-unique-id: done - has-entity-name: todo + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr index 1504952a86d..32717a8af43 100644 --- a/tests/components/onkyo/snapshots/test_media_player.ambr +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -22,7 +22,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -98,7 +98,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100_zone_2', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -162,7 +162,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.tx_nr7100_zone_3', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , From f5d68a4ea40e76358ea29d09839d093cdfd8c9c7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:10:59 +0200 Subject: [PATCH 0491/1118] Simplify getting domains to resolve in bootstrap (#145829) --- homeassistant/bootstrap.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 493b9b1eab6..4e49d6cec7e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -695,10 +695,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" - # Filter out the repeating and common config section [homeassistant] - domains = { - domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN - } + # The common config section [homeassistant] could be filtered here, + # but that is not necessary, since it corresponds to the core integration, + # that is always unconditionally loaded. + domains = {cv.domain_key(key) for key in config} # Add config entry and default domains if not hass.config.recovery_mode: @@ -726,34 +726,28 @@ async def _async_resolve_domains_and_preload( together with all their dependencies. """ domains_to_setup = _get_domains(hass, config) - platform_integrations = conf_util.extract_platform_integrations( - config, BASE_PLATFORMS - ) - # Ensure base platforms that have platform integrations are added to `domains`, - # so they can be setup first instead of discovering them later when a config - # entry setup task notices that it's needed and there is already a long line - # to use the import executor. + + # Also process all base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. # + # Additionally process integrations that are defined under base platforms + # to speed things up. # For example if we have # sensor: # - platform: template # - # `template` has to be loaded to validate the config for sensor - # so we want to start loading `sensor` as soon as we know - # it will be needed. The more platforms under `sensor:`, the longer + # `template` has to be loaded to validate the config for sensor. + # The more platforms under `sensor:`, the longer # it will take to finish setup for `sensor` because each of these # platforms has to be imported before we can validate the config. # # Thankfully we are migrating away from the platform pattern # so this will be less of a problem in the future. - domains_to_setup.update(platform_integrations) - - # Additionally process base platforms since we do not require the manifest - # to list them as dependencies. - # We want to later avoid lock contention when multiple integrations try to load - # their manifests at once. - # Also process integrations that are defined under base platforms - # to speed things up. + platform_integrations = conf_util.extract_platform_integrations( + config, BASE_PLATFORMS + ) additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), From 8d1c789ca2c2e291bc9c60afe9b9f8275c7b9306 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:10:23 +0200 Subject: [PATCH 0492/1118] Replace RuntimeError with TYPE_CHECKING in Tuya (#149227) --- homeassistant/components/tuya/climate.py | 17 ++- homeassistant/components/tuya/cover.py | 16 ++- tests/components/tuya/test_climate.py | 60 ++++++++++ tests/components/tuya/test_cover.py | 137 +++++++++++++++++++++++ 4 files changed, 211 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index d8907b0db9d..370548d67b0 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -307,17 +307,16 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if TYPE_CHECKING: - # We can rely on supported_features from __init__ + # guarded by ClimateEntityFeature.FAN_MODE assert self._fan_mode_dp_code is not None self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_HUMIDITY + assert self._set_humidity is not None self._send_command( [ @@ -355,11 +354,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if self._set_temperature is None: - raise RuntimeError( - "Cannot set target temperature, device doesn't provide methods to" - " set it" - ) + if TYPE_CHECKING: + # guarded by ClimateEntityFeature.TARGET_TEMPERATURE + assert self._set_temperature is not None self._send_command( [ diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index a385a35d903..205a65431dd 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from tuya_sharing import CustomerDevice, Manager @@ -333,10 +333,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if self._set_position is None: - raise RuntimeError( - "Cannot set position, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_POSITION + assert self._set_position is not None self._send_command( [ @@ -364,10 +363,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - if self._tilt is None: - raise RuntimeError( - "Cannot set tilt, device doesn't provide methods to set it" - ) + if TYPE_CHECKING: + # guarded by CoverEntityFeature.SET_TILT_POSITION + assert self._tilt is not None self._send_command( [ diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 9c0e3c31a26..e8aee3f4f96 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -11,6 +11,8 @@ from tuya_sharing import CustomerDevice from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_TEMPERATURE, ) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform @@ -62,6 +64,36 @@ async def test_platform_setup_no_discovery( ) +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_set_temperature( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set temperature service.""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": entity_id, + "temperature": 22.7, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "temp_set", "value": 22}] + ) + + @pytest.mark.parametrize( "mock_device_code", ["kt_serenelife_slpac905wuk_air_conditioner"], @@ -125,3 +157,31 @@ async def test_fan_mode_no_valid_code( }, blocking=True, ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_set_humidity_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not available on this device).""" + entity_id = "climate.air_conditioner" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + blocking=True, + ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 29a6d65978f..24e43dcccec 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -8,9 +8,17 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -57,6 +65,107 @@ async def test_platform_setup_no_discovery( ) +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_open_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test open service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "open"}, + {"code": "percent_control", "value": 0}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_close_service( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test close service.""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "control", "value": "close"}, + {"code": "percent_control", "value": 100}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_set_position( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + { + "entity_id": entity_id, + "position": 25, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "percent_control", "value": 75}, + ], + ) + + @pytest.mark.parametrize( "mock_device_code", ["cl_am43_corded_motor_zigbee_cover"], @@ -89,3 +198,31 @@ async def test_percent_state_on_cover( state = hass.states.get(entity_id) assert state is not None, f"{entity_id} does not exist" assert state.attributes["current_position"] == percent_state + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_set_tilt_position_not_supported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set tilt position service (not available on this device).""" + entity_id = "cover.kitchen_blinds_curtain" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + { + "entity_id": entity_id, + "tilt_position": 50, + }, + blocking=True, + ) From 1f07dd7946388f8b9b54a1b90ef37a615b9f7aa8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:07:56 +0200 Subject: [PATCH 0493/1118] Bump github/codeql-action from 3.29.2 to 3.29.3 (#149220) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8a0af8bd5f9..cbc343b9d98 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.2 + uses: github/codeql-action/init@v3.29.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.2 + uses: github/codeql-action/analyze@v3.29.3 with: category: "/language:python" From e79d42ecfc6e96b2fed08cdbc4496b4f38700a33 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 22 Jul 2025 13:32:45 +0200 Subject: [PATCH 0494/1118] Add missing hyphen to "post-heater" in `vallox` (#149222) --- homeassistant/components/vallox/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 2a074cf2015..f12a5328330 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -34,7 +34,7 @@ "entity": { "binary_sensor": { "post_heater": { - "name": "Post heater" + "name": "Post-heater" } }, "number": { From 49807c9fbe504b121f1254bb30fab7e62447e379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 22 Jul 2025 13:33:03 +0200 Subject: [PATCH 0495/1118] Add set_program service to Miele (#143442) --- homeassistant/components/miele/__init__.py | 13 ++- homeassistant/components/miele/icons.json | 5 + homeassistant/components/miele/services.py | 92 ++++++++++++++++ homeassistant/components/miele/services.yaml | 17 +++ homeassistant/components/miele/strings.json | 22 ++++ tests/components/miele/conftest.py | 1 + tests/components/miele/test_services.py | 110 +++++++++++++++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/miele/services.py create mode 100644 homeassistant/components/miele/services.yaml create mode 100644 tests/components/miele/test_services.py diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 9b9ec81bea9..1cb2fc0fab1 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -7,16 +7,18 @@ from aiohttp import ClientError, ClientResponseError from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -29,6 +31,15 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up service actions.""" + await async_setup_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool: """Set up Miele from a config entry.""" diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 44b51a67c24..1b757a9e113 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -103,5 +103,10 @@ "default": "mdi:snowflake" } } + }, + "services": { + "set_program": { + "service": "mdi:arrow-right-circle-outline" + } } } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py new file mode 100644 index 00000000000..70ea20ccc4a --- /dev/null +++ b/homeassistant/components/miele/services.py @@ -0,0 +1,92 @@ +"""Services for Miele integration.""" + +import logging +from typing import cast + +import aiohttp +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import async_extract_config_entry_ids + +from .const import DOMAIN +from .coordinator import MieleConfigEntry + +ATTR_PROGRAM_ID = "program_id" +ATTR_DURATION = "duration" + + +SERVICE_SET_PROGRAM = "set_program" +SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + }, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: + """Extract config entry from the service call.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries: list[MieleConfigEntry] = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + return target_entries[0] + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + device_reg = dr.async_get(call.hass) + api = config_entry.runtime_data.api + device = call.data[ATTR_DEVICE_ID] + device_entry = device_reg.async_get(device) + + data = {"programId": call.data[ATTR_PROGRAM_ID]} + serial_number = next( + ( + identifier[1] + for identifier in cast(dr.DeviceEntry, device_entry).identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if serial_number is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml new file mode 100644 index 00000000000..486fdf7307b --- /dev/null +++ b/homeassistant/components/miele/services.yaml @@ -0,0 +1,17 @@ +# Services descriptions for Miele integration + +set_program: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 97035da6d5f..865f3313ad5 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1059,8 +1059,30 @@ "config_entry_not_ready": { "message": "Error while loading the integration." }, + "invalid_target": { + "message": "Invalid device targeted." + }, + "set_program_error": { + "message": "'Set program' action failed {status} / {message}." + }, "set_state_error": { "message": "Failed to set state for {entity}." } + }, + "services": { + "set_program": { + "name": "Set program", + "description": "Sets and starts a program on the appliance.", + "fields": { + "device_id": { + "description": "The device to set the program on.", + "name": "Device" + }, + "program_id": { + "description": "The ID of the program to set.", + "name": "Program ID" + } + } + } } } diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 94112e29143..7b3c3f35f7e 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -125,6 +125,7 @@ def mock_miele_client( client.get_devices.return_value = device_fixture client.get_actions.return_value = action_fixture client.get_programs.return_value = programs_fixture + client.set_program.return_value = None yield client diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py new file mode 100644 index 00000000000..8b33c17d69f --- /dev/null +++ b/tests/components/miele/test_services.py @@ -0,0 +1,110 @@ +"""Tests the services provided by the miele integration.""" + +from unittest.mock import MagicMock + +from aiohttp import ClientResponseError +import pytest +from voluptuous import MultipleInvalid + +from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_APPLIANCE = "Dummy_Appliance_1" + + +async def test_services( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + { + ATTR_DEVICE_ID: device.id, + ATTR_PROGRAM_ID: 24, + }, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 24} + ) + + +async def test_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.set_program.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Set program' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, {"programId": 1} + ) + + +async def test_service_validation_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Tests that the custom services handle bad data.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test missing program_id + with pytest.raises(MultipleInvalid, match="required key not provided"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid program_id + with pytest.raises(MultipleInvalid, match="expected int for dictionary value"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": device.id, ATTR_PROGRAM_ID: "invalid"}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() + + # Test invalid device + with pytest.raises(ServiceValidationError, match="Invalid device targeted"): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM, + {"device_id": "invalid_device", ATTR_PROGRAM_ID: 1}, + blocking=True, + ) + mock_miele_client.set_program.assert_not_called() From e5c7e04329a324fbefe979796612101b2349774c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 22 Jul 2025 13:43:41 +0200 Subject: [PATCH 0496/1118] Introduce base entity in Open Router (#148910) --- homeassistant/components/open_router/const.py | 3 +- .../components/open_router/conversation.py | 174 +--------------- .../components/open_router/entity.py | 185 ++++++++++++++++++ .../components/open_router/strings.json | 2 +- 4 files changed, 195 insertions(+), 169 deletions(-) create mode 100644 homeassistant/components/open_router/entity.py diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index 9fbce10da4e..7316d45c3e5 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -2,13 +2,12 @@ import logging -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT from homeassistant.helpers import llm DOMAIN = "open_router" LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" CONF_RECOMMENDED = "recommended" RECOMMENDED_CONVERSATION_OPTIONS = { diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 06196565aad..826931d3da7 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -1,39 +1,16 @@ """Conversation support for OpenRouter.""" -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, Literal - -import openai -from openai import NOT_GIVEN -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionMessage, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, - ChatCompletionUserMessageParam, -) -from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import llm -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenRouterConfigEntry -from .const import CONF_PROMPT, DOMAIN, LOGGER - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 +from .const import DOMAIN +from .entity import OpenRouterEntity async def async_setup_entry( @@ -49,106 +26,14 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, - custom_serializer: Callable[[Any], Any] | None, -) -> ChatCompletionToolParam: - """Format tool specification.""" - tool_spec = FunctionDefinition( - name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), - ) - if tool.description: - tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) - - -def _convert_content_to_chat_message( - content: conversation.Content, -) -> ChatCompletionMessageParam | None: - """Convert any native chat message for this agent to the native format.""" - LOGGER.debug("_convert_content_to_chat_message=%s", content) - if isinstance(content, conversation.ToolResultContent): - return ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - - role: Literal["user", "assistant", "system"] = content.role - if role == "system" and content.content: - return ChatCompletionSystemMessageParam(role="system", content=content.content) - - if role == "user" and content.content: - return ChatCompletionUserMessageParam(role="user", content=content.content) - - if role == "assistant": - param = ChatCompletionAssistantMessageParam( - role="assistant", - content=content.content, - ) - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - param["tool_calls"] = [ - ChatCompletionMessageToolCallParam( - type="function", - id=tool_call.id, - function=Function( - arguments=json.dumps(tool_call.tool_args), - name=tool_call.tool_name, - ), - ) - for tool_call in content.tool_calls - ] - return param - LOGGER.warning("Could not convert message to Completions API: %s", content) - return None - - -def _decode_tool_arguments(arguments: str) -> Any: - """Decode tool call arguments.""" - try: - return json.loads(arguments) - except json.JSONDecodeError as err: - raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err - - -async def _transform_response( - message: ChatCompletionMessage, -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the OpenRouter message to a ChatLog format.""" - data: conversation.AssistantContentDeltaDict = { - "role": message.role, - "content": message.content, - } - if message.tool_calls: - data["tool_calls"] = [ - llm.ToolInput( - id=tool_call.id, - tool_name=tool_call.function.name, - tool_args=_decode_tool_arguments(tool_call.function.arguments), - ) - for tool_call in message.tool_calls - ] - yield data - - -class OpenRouterConversationEntity(conversation.ConversationEntity): +class OpenRouterConversationEntity(OpenRouterEntity, conversation.ConversationEntity): """OpenRouter conversation agent.""" - _attr_has_entity_name = True _attr_name = None def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self.model = subentry.data[CONF_MODEL] - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - entry_type=DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -164,7 +49,7 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): user_input: conversation.ConversationInput, chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: - """Process a sentence.""" + """Process the user input and call the API.""" options = self.subentry.data try: @@ -177,49 +62,6 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[ChatCompletionToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - messages = [ - m - for content in chat_log.content - if (m := _convert_content_to_chat_message(content)) - ] - - client = self.entry.runtime_data - - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - result = await client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools or NOT_GIVEN, - user=chat_log.conversation_id, - extra_headers={ - "X-Title": "Home Assistant", - "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", - }, - ) - except openai.OpenAIError as err: - LOGGER.error("Error talking to API: %s", err) - raise HomeAssistantError("Error talking to API") from err - - result_message = result.choices[0].message - - messages.extend( - [ - msg - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_response(result_message) - ) - if (msg := _convert_content_to_chat_message(content)) - ] - ) - if not chat_log.unresponded_tool_results: - break + await self._async_handle_chat_log(chat_log) return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py new file mode 100644 index 00000000000..e706656d377 --- /dev/null +++ b/homeassistant/components/open_router/entity.py @@ -0,0 +1,185 @@ +"""Base entity for Open Router.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai import NOT_GIVEN +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OpenRouterConfigEntry +from .const import DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenRouter message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + ] + yield data + + +class OpenRouterEntity(Entity): + """Base entity for Open Router.""" + + _attr_has_entity_name = True + + def __init__(self, entry: OpenRouterConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None: + """Generate an answer for the chat log.""" + + tools: list[ChatCompletionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + tools=tools or NOT_GIVEN, + user=chat_log.conversation_id, + extra_headers={ + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + ) + except openai.OpenAIError as err: + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + result_message = result.choices[0].message + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index 6e6674dac06..91c4cc350ae 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -25,7 +25,7 @@ "description": "Configure the new conversation agent", "data": { "model": "Model", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" }, "data_description": { From c075134845d538e97002d6c89db2b3e092a9dfca Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 22 Jul 2025 13:58:33 +0200 Subject: [PATCH 0497/1118] Use OpenRouterClient to get the models (#148903) --- .../components/open_router/config_flow.py | 24 +++-- tests/components/open_router/conftest.py | 29 ++---- .../open_router/fixtures/models.json | 92 +++++++++++++++++++ .../open_router/test_config_flow.py | 14 +-- .../open_router/test_conversation.py | 2 +- 5 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 tests/components/open_router/fixtures/models.json diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index e228492e3a1..96f3769575b 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import Any -from openai import AsyncOpenAI -from python_open_router import OpenRouterClient, OpenRouterError +from python_open_router import Model, OpenRouterClient, OpenRouterError import voluptuous as vol from homeassistant.config_entries import ( @@ -20,7 +19,6 @@ from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import callback from homeassistant.helpers import llm from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -85,7 +83,7 @@ class ConversationFlowHandler(ConfigSubentryFlow): def __init__(self) -> None: """Initialize the subentry flow.""" - self.options: dict[str, str] = {} + self.models: dict[str, Model] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -95,14 +93,18 @@ class ConversationFlowHandler(ConfigSubentryFlow): if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) return self.async_create_entry( - title=self.options[user_input[CONF_MODEL]], data=user_input + title=self.models[user_input[CONF_MODEL]].name, data=user_input ) entry = self._get_entry() - client = AsyncOpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=entry.data[CONF_API_KEY], - http_client=get_async_client(self.hass), + client = OpenRouterClient( + entry.data[CONF_API_KEY], async_get_clientsession(self.hass) ) + models = await client.get_models() + self.models = {model.id: model for model in models} + options = [ + SelectOptionDict(value=model.id, label=model.name) for model in models + ] + hass_apis: list[SelectOptionDict] = [ SelectOptionDict( label=api.name, @@ -110,10 +112,6 @@ class ConversationFlowHandler(ConfigSubentryFlow): ) for api in llm.async_get_apis(self.hass) ] - options = [] - async for model in client.with_options(timeout=10.0).models.list(): - options.append(SelectOptionDict(value=model.id, label=model.name)) # type: ignore[attr-defined] - self.options[model.id] = model.name # type: ignore[attr-defined] return self.async_show_form( step_id="user", data_schema=vol.Schema( diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index ca679c2ebef..7bb967f369f 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -3,12 +3,13 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from openai.types import CompletionUsage from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice import pytest +from python_open_router import ModelsDataWrapper from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN from homeassistant.config_entries import ConfigSubentryData @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture @pytest.fixture @@ -40,7 +41,7 @@ def enable_assist() -> bool: def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: """Mock conversation subentry data.""" res: dict[str, Any] = { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "You are a helpful assistant.", } if enable_assist: @@ -82,24 +83,8 @@ class Model: @pytest.fixture async def mock_openai_client() -> AsyncGenerator[AsyncMock]: """Initialize integration.""" - with ( - patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client, - patch( - "homeassistant.components.open_router.config_flow.AsyncOpenAI", - new=mock_client, - ), - ): + with patch("homeassistant.components.open_router.AsyncOpenAI") as mock_client: client = mock_client.return_value - client.with_options = MagicMock() - client.with_options.return_value.models = MagicMock() - client.with_options.return_value.models.list.return_value = ( - get_generator_from_data( - [ - Model(id="gpt-4", name="GPT-4"), - Model(id="gpt-3.5-turbo", name="GPT-3.5 Turbo"), - ], - ) - ) client.chat.completions.create = AsyncMock( return_value=ChatCompletion( id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", @@ -128,13 +113,15 @@ async def mock_openai_client() -> AsyncGenerator[AsyncMock]: @pytest.fixture -async def mock_open_router_client() -> AsyncGenerator[AsyncMock]: +async def mock_open_router_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: """Initialize integration.""" with patch( "homeassistant.components.open_router.config_flow.OpenRouterClient", autospec=True, ) as mock_client: client = mock_client.return_value + models = await async_load_fixture(hass, "models.json", DOMAIN) + client.get_models.return_value = ModelsDataWrapper.from_json(models).data yield client diff --git a/tests/components/open_router/fixtures/models.json b/tests/components/open_router/fixtures/models.json new file mode 100644 index 00000000000..0a35686094e --- /dev/null +++ b/tests/components/open_router/fixtures/models.json @@ -0,0 +1,92 @@ +{ + "data": [ + { + "id": "openai/gpt-3.5-turbo", + "canonical_slug": "openai/gpt-3.5-turbo", + "hugging_face_id": null, + "name": "OpenAI: GPT-3.5 Turbo", + "created": 1695859200, + "description": "This model is a variant of GPT-3.5 Turbo tuned for instructional prompts and omitting chat-related optimizations. Training data: up to Sep 2021.", + "context_length": 4095, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": "chatml" + }, + "pricing": { + "prompt": "0.0000015", + "completion": "0.000002", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 4095, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "response_format" + ] + }, + { + "id": "openai/gpt-4", + "canonical_slug": "openai/gpt-4", + "hugging_face_id": null, + "name": "OpenAI: GPT-4", + "created": 1685232000, + "description": "OpenAI's flagship model, GPT-4 is a large-scale multimodal language model capable of solving difficult problems with greater accuracy than previous models due to its broader general knowledge and advanced reasoning capabilities. Training data: up to Sep 2021.", + "context_length": 8191, + "architecture": { + "modality": "text->text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "GPT", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00003", + "completion": "0.00006", + "request": "0", + "image": "0", + "web_search": "0", + "internal_reasoning": "0" + }, + "top_provider": { + "context_length": 8191, + "max_completion_tokens": 4096, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "max_tokens", + "temperature", + "top_p", + "tools", + "tool_choice", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + "response_format" + ] + } + ] +} diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 5e7a67d4a2b..0720f6d90f5 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -124,13 +124,14 @@ async def test_create_conversation_agent( assert result["step_id"] == "user" assert result["data_schema"].schema["model"].config["options"] == [ - {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, ] result = await hass.config_entries.subentries.async_configure( result["flow_id"], { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], }, @@ -138,7 +139,7 @@ async def test_create_conversation_agent( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: ["assist"], } @@ -165,13 +166,14 @@ async def test_create_conversation_agent_no_control( assert result["step_id"] == "user" assert result["data_schema"].schema["model"].config["options"] == [ - {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + {"value": "openai/gpt-3.5-turbo", "label": "OpenAI: GPT-3.5 Turbo"}, + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, ] result = await hass.config_entries.subentries.async_configure( result["flow_id"], { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", CONF_LLM_HASS_API: [], }, @@ -179,6 +181,6 @@ async def test_create_conversation_agent_no_control( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - CONF_MODEL: "gpt-3.5-turbo", + CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", } diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 84742191efd..93f8264801a 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -65,7 +65,7 @@ async def test_default_prompt( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert mock_chat_log.content[1:] == snapshot call = mock_openai_client.chat.completions.create.call_args_list[0][1] - assert call["model"] == "gpt-3.5-turbo" + assert call["model"] == "openai/gpt-3.5-turbo" assert call["extra_headers"] == { "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", "X-Title": "Home Assistant", From 3f67ba4c02e3dffe19fed36aa0203d856c146915 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:06:03 +0200 Subject: [PATCH 0498/1118] Add support for ELV-SH-WSM to homematicip (#149098) --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/sensor.py | 83 ++++++++++ .../components/homematicip_cloud/valve.py | 59 +++++++ .../fixtures/homematicip_cloud.json | 155 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_sensor.py | 65 ++++++++ .../homematicip_cloud/test_valve.py | 35 ++++ 7 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homematicip_cloud/valve.py create mode 100644 tests/components/homematicip_cloud/test_valve.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 2b72794b323..d4c0b1a45ca 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.LOCK, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, Platform.WEATHER, ] diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 95de7f15af0..1ed483b86ad 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -33,6 +33,7 @@ from homematicip.device import ( TemperatureHumiditySensorOutdoor, TemperatureHumiditySensorWithoutDisplay, TiltVibrationSensor, + WateringActuator, WeatherSensor, WeatherSensorPlus, WeatherSensorPro, @@ -167,6 +168,29 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: HomematicipTiltStateSensor(hap, device), HomematicipTiltAngleSensor(hap, device), ], + WateringActuator: lambda device: [ + entity + for ch in device.functionalChannels + if ch.functionalChannelType + == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + for entity in ( + HomematicipWaterFlowSensor( + hap, device, channel=ch.index, post="currentWaterFlow" + ), + HomematicipWaterVolumeSensor( + hap, + device, + channel=ch.index, + post="waterVolume", + attribute="waterVolume", + ), + HomematicipWaterVolumeSinceOpenSensor( + hap, + device, + channel=ch.index, + ), + ) + ], WeatherSensor: lambda device: [ HomematicipTemperatureSensor(hap, device), HomematicipHumiditySensor(hap, device), @@ -267,6 +291,65 @@ async def async_setup_entry( async_add_entities(entities) +class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering flow sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolumeFlowRate.LITERS_PER_MINUTE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, hap: HomematicipHAP, device: Device, channel: int, post: str + ) -> None: + """Initialize the watering flow sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + + @property + def native_value(self) -> float | None: + """Return the state.""" + return self.functional_channel.waterFlow + + +class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP watering volume sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__( + self, + hap: HomematicipHAP, + device: Device, + channel: int, + post: str, + attribute: str, + ) -> None: + """Initialize the watering volume sensor device.""" + super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True) + self._attribute_name = attribute + + @property + def native_value(self) -> float | None: + """Return the state.""" + return getattr(self.functional_channel, self._attribute_name, None) + + +class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor): + """Representation of the HomematicIP watering volume since open sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.LITERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the watering flow volume since open device.""" + super().__init__( + hap, + device, + channel=channel, + post="waterVolumeSinceOpen", + attribute="waterVolumeSinceOpen", + ) + + class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP tilt angle sensor.""" diff --git a/homeassistant/components/homematicip_cloud/valve.py b/homeassistant/components/homematicip_cloud/valve.py new file mode 100644 index 00000000000..aaeaa3c565c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/valve.py @@ -0,0 +1,59 @@ +"""Support for HomematicIP Cloud valve devices.""" + +from homematicip.base.functionalChannels import FunctionalChannelType +from homematicip.device import Device + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import HomematicipGenericEntity +from .hap import HomematicIPConfigEntry, HomematicipHAP + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomematicIPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the HomematicIP valves from a config entry.""" + hap = config_entry.runtime_data + entities = [ + HomematicipWateringValve(hap, device, ch.index) + for device in hap.home.devices + for ch in device.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.WATERING_ACTUATOR_CHANNEL + ] + + async_add_entities(entities) + + +class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity): + """Representation of a HomematicIP valve.""" + + _attr_reports_position = False + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the valve.""" + super().__init__( + hap, device=device, channel=channel, post="watering", is_multi_channel=True + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.functional_channel.set_watering_switch_state_async(True) + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.functional_channel.set_watering_switch_state_async(False) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.functional_channel.wateringActive is False diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index c9eab0cf4f5..44d8cc33c80 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8936,6 +8936,161 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", "type": "RGBW_DIMMER", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000SHWSM": { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.10", + "firmwareVersionInteger": 65546, + "functionalChannels": { + "0": { + "altitude": null, + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "dataDecodingFailedError": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F71100000000000SHWSM", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": false, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000022"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -46, + "rssiPeerValue": -43, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDataDecodingFailedError": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceMountingModuleError": false, + "IFeatureDeviceOverheated": true, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTempSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": true, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": true, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": true, + "IOptionalFeatureDeviceWaterError": true, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": false, + "valveWaterError": false + }, + "1": { + "channelRole": "WATERING_ACTUATOR", + "deviceId": "3014F71100000000000SHWSM", + "functionalChannelType": "WATERING_ACTUATOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000023"], + "index": 1, + "label": "", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IFeatureWateringGroupActuatorChannel": true, + "IFeatureWateringProfileActuatorChannel": true + }, + "userDesiredProfileMode": "AUTOMATIC", + "waterFlow": 12.0, + "waterVolume": 455.0, + "waterVolumeSinceOpen": 67.0, + "wateringActive": false, + "wateringOnTime": 3600.0 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000SHWSM", + "label": "Bewaesserungsaktor", + "lastStatusUpdate": 1749501203047, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 586, + "modelType": "ELV-SH-WSM", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000SHWSM", + "type": "WATERING_ACTUATOR", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 4fb9f9eede8..8bff1798255 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 335 + assert len(mock_hap.hmip_device_by_entity_id) == 340 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index a107214b373..77e90ccaff6 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -35,6 +35,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant @@ -796,3 +798,66 @@ async def test_hmip_absolute_humidity_sensor_invalid_value( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNKNOWN + + +async def test_hmip_water_valve_current_water_flow( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipCurrentWaterFlow.""" + entity_id = "sensor.bewaesserungsaktor_currentwaterflow" + entity_name = "Bewaesserungsaktor currentWaterFlow" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "12.0" + assert ( + ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == UnitOfVolumeFlowRate.LITERS_PER_MINUTE + ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +async def test_hmip_water_valve_water_volume( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolume.""" + entity_id = "sensor.bewaesserungsaktor_watervolume" + entity_name = "Bewaesserungsaktor waterVolume" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "455.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + + +async def test_hmip_water_valve_water_volume_since_open( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipWaterVolumeSinceOpen.""" + entity_id = "sensor.bewaesserungsaktor_watervolumesinceopen" + entity_name = "Bewaesserungsaktor waterVolumeSinceOpen" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "67.0" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfVolume.LITERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/homematicip_cloud/test_valve.py b/tests/components/homematicip_cloud/test_valve.py new file mode 100644 index 00000000000..5c2840dc28f --- /dev/null +++ b/tests/components/homematicip_cloud/test_valve.py @@ -0,0 +1,35 @@ +"""Test HomematicIP Cloud valve entities.""" + +from homeassistant.components.valve import SERVICE_OPEN_VALVE, ValveState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entity_basics + + +async def test_watering_valve( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicIP watering valve.""" + entity_id = "valve.bewaesserungsaktor_watering" + entity_name = "Bewaesserungsaktor watering" + device_model = "ELV-SH-WSM" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewaesserungsaktor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == ValveState.CLOSED + + await hass.services.async_call( + Platform.VALVE, SERVICE_OPEN_VALVE, {"entity_id": entity_id}, blocking=True + ) + + await async_manipulate_test_data( + hass, hmip_device, "wateringActive", True, channel=1 + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == ValveState.OPEN From 5a771b501d3f2aed01e91ade9ab41d5c9979897c Mon Sep 17 00:00:00 2001 From: wedsa5 Date: Tue, 22 Jul 2025 06:07:34 -0600 Subject: [PATCH 0499/1118] Fix ColorMode.WHITE support in Tuya (#126242) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Erik Montnemery --- homeassistant/components/tuya/light.py | 36 ++++++---- .../components/tuya/snapshots/test_light.ambr | 20 ++---- tests/components/tuya/test_light.py | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b6d0332e03a..698ca302310 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_WHITE, ColorMode, LightEntity, LightEntityDescription, @@ -488,6 +489,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _color_data_type: ColorTypeData | None = None _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None + _white_color_mode = ColorMode.COLOR_TEMP _fixed_color_mode: ColorMode | None = None _attr_min_color_temp_kelvin = 2000 # 500 Mireds _attr_max_color_temp_kelvin = 6500 # 153 Mireds @@ -526,6 +528,13 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ): self._color_temp = int_type color_modes.add(ColorMode.COLOR_TEMP) + # If entity does not have color_temp, check if it has work_mode "white" + elif color_mode_enum := self.find_dpcode( + description.color_mode, dptype=DPType.ENUM, prefer_function=True + ): + if WorkMode.WHITE.value in color_mode_enum.range: + color_modes.add(ColorMode.WHITE) + self._white_color_mode = ColorMode.WHITE if ( dpcode := self.find_dpcode(description.color_data, prefer_function=True) @@ -566,15 +575,17 @@ class TuyaLightEntity(TuyaEntity, LightEntity): """Turn on or control the light.""" commands = [{"code": self.entity_description.key, "value": True}] - if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: - if self._color_mode_dpcode: - commands += [ - { - "code": self._color_mode_dpcode, - "value": WorkMode.WHITE, - }, - ] + if self._color_mode_dpcode and ( + ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs + ): + commands += [ + { + "code": self._color_mode_dpcode, + "value": WorkMode.WHITE, + }, + ] + if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: commands += [ { "code": self._color_temp.dpcode, @@ -596,6 +607,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): or ( ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS + and ATTR_WHITE not in kwargs and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): @@ -755,15 +767,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity): # The light supports only a single color mode, return it return self._fixed_color_mode - # The light supports both color temperature and HS, determine which mode the - # light is in. We consider it to be in HS color mode, when work mode is anything - # else than "white". + # The light supports both white (with or without adjustable color temperature) + # and HS, determine which mode the light is in. We consider it to be in HS color + # mode, when work mode is anything else than "white". if ( self._color_mode_dpcode and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE ): return ColorMode.HS - return ColorMode.COLOR_TEMP + return self._white_color_mode def _get_color_data(self) -> ColorData | None: """Get current color data from device.""" diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index c691aae2cc1..5fcf58dda6d 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -64,6 +64,7 @@ 'capabilities': dict({ 'supported_color_modes': list([ , + , ]), }), 'config_entry_id': , @@ -99,25 +100,16 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': 138, - 'color_mode': , + 'color_mode': , 'friendly_name': 'Garage light', - 'hs_color': tuple( - 243.0, - 86.0, - ), - 'rgb_color': tuple( - 47, - 36, - 255, - ), + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , + , ]), 'supported_features': , - 'xy_color': tuple( - 0.148, - 0.055, - ), + 'xy_color': None, }), 'context': , 'entity_id': 'light.garage_light', diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 33d0e36715e..0d4706a5563 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -8,6 +8,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -55,3 +60,66 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_smart_light_bulb"], +) +async def test_turn_on_white( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn_on service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "entity_id": entity_id, + "white": 150, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, + [ + {"code": "switch_led", "value": True}, + {"code": "work_mode", "value": "white"}, + ], + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["dj_smart_light_bulb"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn_off service.""" + entity_id = "light.garage_light" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_led", "value": False}] + ) From dd399ef59f40316ab5d5841bcb754183a7967370 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 22 Jul 2025 14:35:57 +0200 Subject: [PATCH 0500/1118] Refactor EntityPlatform (#147927) --- .../components/generic/config_flow.py | 20 +- homeassistant/components/number/__init__.py | 4 +- homeassistant/components/sensor/__init__.py | 4 +- .../components/time_date/config_flow.py | 21 +- homeassistant/helpers/entity.py | 50 ++-- homeassistant/helpers/entity_platform.py | 216 +++++++++++++----- tests/components/go2rtc/test_init.py | 2 +- tests/helpers/test_entity.py | 4 +- tests/helpers/test_entity_platform.py | 53 +++++ 9 files changed, 256 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index b20793fe060..0621ca369db 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import contextlib -from datetime import datetime, timedelta +from datetime import datetime from errno import EHOSTUNREACH, EIO import io import logging @@ -52,9 +52,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -569,18 +568,9 @@ async def ws_start_preview( ) user_input = flow.preview_image_settings - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=CAMERA_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=CAMERA_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() ha_still_url = None ha_stream_url = None diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 054f888ba33..79ed56d2a75 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -387,7 +387,9 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 9948860fd5f..88f8dbbdaa2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -523,7 +523,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Fourth priority: Unit translation if (translation_key := self._unit_of_measurement_translation_key) and ( unit_of_measurement - := self.platform.default_language_platform_translations.get(translation_key) + := self.platform_data.default_language_platform_translations.get( + translation_key + ) ): if native_unit_of_measurement is not None: raise ValueError( diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 9ae98992acb..364bf26d1aa 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta import logging from typing import Any @@ -12,7 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -24,7 +23,6 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) -from homeassistant.setup import async_prepare_setup_platform from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES from .sensor import TimeDateSensor @@ -99,18 +97,9 @@ async def ws_start_preview( """Generate a preview.""" validated = USER_SCHEMA(msg["user_input"]) - # Create an EntityPlatform, needed for name translations - platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) - entity_platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=SENSOR_DOMAIN, - platform_name=DOMAIN, - platform=platform, - scan_interval=timedelta(seconds=3600), - entity_namespace=None, - ) - await entity_platform.async_load_translations() + # Create PlatformData, needed for name translations + platform_data = PlatformData(hass=hass, domain=SENSOR_DOMAIN, platform_name=DOMAIN) + await platform_data.async_load_translations() @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -123,7 +112,7 @@ async def ws_start_preview( preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) preview_entity.hass = hass - preview_entity.platform = entity_platform + preview_entity.platform_data = platform_data connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 352a77af837..6272495bcec 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -66,7 +66,7 @@ from .typing import UNDEFINED, StateType, UndefinedType timer = time.time if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from .entity_platform import EntityPlatform, PlatformData _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 @@ -449,6 +449,7 @@ class Entity( # While not purely typed, it makes typehinting more useful for us # and removes the need for constant None checks or asserts. platform: EntityPlatform = None # type: ignore[assignment] + platform_data: PlatformData = None # type: ignore[assignment] # Entity description instance for this Entity entity_description: EntityDescription @@ -593,7 +594,7 @@ class Entity( return not self._attr_name if ( name_translation_key := self._name_translation_key - ) and name_translation_key in self.platform.platform_translations: + ) and name_translation_key in self.platform_data.platform_translations: return False if hasattr(self, "entity_description"): return not self.entity_description.name @@ -616,9 +617,9 @@ class Entity( if not self.has_entity_name: return None device_class_key = self.device_class or "_" - platform = self.platform + platform_domain = self.platform_data.domain name_translation_key = ( - f"component.{platform.domain}.entity_component.{device_class_key}.name" + f"component.{platform_domain}.entity_component.{device_class_key}.name" ) return component_translations.get(name_translation_key) @@ -626,13 +627,13 @@ class Entity( def _object_id_device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" return self._device_class_name_helper( - self.platform.object_id_component_translations + self.platform_data.object_id_component_translations ) @cached_property def _device_class_name(self) -> str | None: """Return a translated name of the entity based on its device class.""" - return self._device_class_name_helper(self.platform.component_translations) + return self._device_class_name_helper(self.platform_data.component_translations) def _default_to_device_class_name(self) -> bool: """Return True if an unnamed entity should be named by its device class.""" @@ -643,9 +644,9 @@ class Entity( """Return translation key for entity name.""" if self.translation_key is None: return None - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.name" ) @@ -654,14 +655,14 @@ class Entity( """Return translation key for unit of measurement.""" if self.translation_key is None: return None - if self.platform is None: + if self.platform_data is None: raise ValueError( f"Entity {type(self)} cannot have a translation key for " "unit of measurement before being added to the entity platform" ) - platform = self.platform + platform_data = self.platform_data return ( - f"component.{platform.platform_name}.entity.{platform.domain}" + f"component.{platform_data.platform_name}.entity.{platform_data.domain}" f".{self.translation_key}.unit_of_measurement" ) @@ -724,13 +725,13 @@ class Entity( # value. type.__getattribute__(self.__class__, "name") is type.__getattribute__(Entity, "name") - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - and self.platform + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + and self.platform_data ): name = self._name_internal( self._object_id_device_class_name, - self.platform.object_id_platform_translations, + self.platform_data.object_id_platform_translations, ) else: name = self.name @@ -739,13 +740,13 @@ class Entity( @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if not self.platform: + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + if not self.platform_data: return self._name_internal(None, {}) return self._name_internal( self._device_class_name, - self.platform.platform_translations, + self.platform_data.platform_translations, ) @cached_property @@ -986,7 +987,7 @@ class Entity( raise RuntimeError(f"Attribute hass is None for {self}") # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] report_issue = self._suggest_report_issue() # type: ignore[unreachable] _LOGGER.warning( @@ -1351,6 +1352,7 @@ class Entity( self.hass = hass self.platform = platform + self.platform_data = platform.platform_data self.parallel_updates = parallel_updates self._platform_state = EntityPlatformState.ADDING @@ -1494,7 +1496,7 @@ class Entity( Not to be extended by integrations. """ # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 + # EntityComponent and can be removed in HA Core 2026.8 if self.platform: del entity_sources(self.hass)[self.entity_id] @@ -1626,9 +1628,9 @@ class Entity( def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - platform_name = self.platform.platform_name if self.platform else None + # The check for self.platform_data guards against integrations not using an + # EntityComponent and can be removed in HA Core 2026.8 + platform_name = self.platform_data.platform_name if self.platform_data else None return async_suggest_report_issue( self.hass, integration_domain=platform_name, module=type(self).__module__ ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e798e85ed02..bf089dae765 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -44,6 +44,7 @@ from . import ( service, translation, ) +from .deprecation import deprecated_function from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later from .issue_registry import IssueSeverity, async_create_issue @@ -126,6 +127,77 @@ class EntityPlatformModule(Protocol): """Set up an integration platform from a config entry.""" +class PlatformData: + """Information about a platform, used by entities.""" + + def __init__( + self, + hass: HomeAssistant, + *, + domain: str, + platform_name: str, + ) -> None: + """Initialize the base entity platform.""" + self.hass = hass + self.domain = domain + self.platform_name = platform_name + self.component_translations: dict[str, str] = {} + self.platform_translations: dict[str, str] = {} + self.object_id_component_translations: dict[str, str] = {} + self.object_id_platform_translations: dict[str, str] = {} + self.default_language_platform_translations: dict[str, str] = {} + + async def _async_get_translations( + self, language: str, category: str, integration: str + ) -> dict[str, str]: + """Get translations for a language, category, and integration.""" + try: + return await translation.async_get_translations( + self.hass, language, category, {integration} + ) + except Exception as err: # noqa: BLE001 + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + config_language = hass.config.language + self.component_translations = await self._async_get_translations( + config_language, "entity_component", self.domain + ) + self.platform_translations = await self._async_get_translations( + config_language, "entity", self.platform_name + ) + if object_id_language == config_language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await self._async_get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await self._async_get_translations( + object_id_language, "entity", self.platform_name + ) + if config_language == languages.DEFAULT_LANGUAGE: + self.default_language_platform_translations = self.platform_translations + else: + self.default_language_platform_translations = ( + await self._async_get_translations( + languages.DEFAULT_LANGUAGE, "entity", self.platform_name + ) + ) + + class EntityPlatform: """Manage the entities for a single platform. @@ -147,8 +219,6 @@ class EntityPlatform: """Initialize the entity platform.""" self.hass = hass self.logger = logger - self.domain = domain - self.platform_name = platform_name self.platform = platform self.scan_interval = scan_interval self.scan_interval_seconds = scan_interval.total_seconds() @@ -157,11 +227,6 @@ class EntityPlatform: # Storage for entities for this specific platform only # which are indexed by entity_id self.entities: dict[str, Entity] = {} - self.component_translations: dict[str, str] = {} - self.platform_translations: dict[str, str] = {} - self.object_id_component_translations: dict[str, str] = {} - self.object_id_platform_translations: dict[str, str] = {} - self.default_language_platform_translations: dict[str, str] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -195,6 +260,10 @@ class EntityPlatform: DATA_DOMAIN_PLATFORM_ENTITIES, {} ).setdefault(key, {}) + self.platform_data = PlatformData( + hass, domain=domain, platform_name=platform_name + ) + def __repr__(self) -> str: """Represent an EntityPlatform.""" return ( @@ -362,7 +431,7 @@ class EntityPlatform: hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - await self.async_load_translations() + await self.platform_data.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -457,56 +526,6 @@ class EntityPlatform: finally: warn_task.cancel() - async def _async_get_translations( - self, language: str, category: str, integration: str - ) -> dict[str, str]: - """Get translations for a language, category, and integration.""" - try: - return await translation.async_get_translations( - self.hass, language, category, {integration} - ) - except Exception as err: # noqa: BLE001 - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - - async def async_load_translations(self) -> None: - """Load translations.""" - hass = self.hass - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - config_language = hass.config.language - self.component_translations = await self._async_get_translations( - config_language, "entity_component", self.domain - ) - self.platform_translations = await self._async_get_translations( - config_language, "entity", self.platform_name - ) - if object_id_language == config_language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await self._async_get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await self._async_get_translations( - object_id_language, "entity", self.platform_name - ) - if config_language == languages.DEFAULT_LANGUAGE: - self.default_language_platform_translations = self.platform_translations - else: - self.default_language_platform_translations = ( - await self._async_get_translations( - languages.DEFAULT_LANGUAGE, "entity", self.platform_name - ) - ) - def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -1120,6 +1139,87 @@ class EntityPlatform: ]: await asyncio.gather(*tasks) + @property + def domain(self) -> str: + """Return the domain (e.g. light).""" + return self.platform_data.domain + + @property + def platform_name(self) -> str: + """Return the platform name (e.g hue).""" + return self.platform_data.platform_name + + @property + @deprecated_function( + "platform_data.component_translations", + breaks_in_ha_version="2026.8", + ) + def component_translations(self) -> dict[str, str]: + """Return the component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.component_translations + + @property + @deprecated_function( + "platform_data.platform_translations", + breaks_in_ha_version="2026.8", + ) + def platform_translations(self) -> dict[str, str]: + """Return the platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.platform_translations + + @property + @deprecated_function( + "platform_data.object_id_component_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_component_translations(self) -> dict[str, str]: + """Return the object ID component translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_component_translations + + @property + @deprecated_function( + "platform_data.object_id_platform_translations", + breaks_in_ha_version="2026.8", + ) + def object_id_platform_translations(self) -> dict[str, str]: + """Return the object ID platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.object_id_platform_translations + + @property + @deprecated_function( + "platform_data.default_language_platform_translations", + breaks_in_ha_version="2026.8", + ) + def default_language_platform_translations(self) -> dict[str, str]: + """Return the default language platform translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return self.platform_data.default_language_platform_translations + + @deprecated_function( + "platform_data.async_load_translations", + breaks_in_ha_version="2026.8", + ) + async def async_load_translations(self) -> None: + """Load translations. + + Will be removed in Home Assistant Core 2026.8. + """ + return await self.platform_data.async_load_translations() + @callback def async_calculate_suggested_object_id( diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 0a071f45ef7..e77e61346b6 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -685,7 +685,7 @@ async def test_generic_workaround( rest_client.get_jpeg_snapshot.return_value = image_bytes camera.set_stream_source("https://my_stream_url.m3u8") - with patch.object(camera.platform, "platform_name", "generic"): + with patch.object(camera.platform.platform_data, "platform_name", "generic"): image = await async_get_image(hass, camera.entity_id) assert image.content == image_bytes diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 30b25e9725d..3064d8d4260 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -781,7 +781,7 @@ async def test_warn_slow_write_state( mock_entity = entity.Entity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): @@ -809,7 +809,7 @@ async def test_warn_slow_write_state_custom_component( mock_entity = CustomComponentEntity() mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" - mock_entity.platform = MagicMock(platform_name="hue") + mock_entity.platform_data = MagicMock(platform_name="hue") mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 08510364eba..53331b676fe 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2447,3 +2447,56 @@ async def test_add_entity_unknown_subentry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.parametrize( + "deprecated_attribute", + [ + "component_translations", + "platform_translations", + "object_id_component_translations", + "object_id_platform_translations", + "default_language_platform_translations", + ], +) +async def test_deprecated_attributes( + hass: HomeAssistant, + deprecated_attribute: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + assert getattr(entity_platform, deprecated_attribute) is getattr( + entity_platform.platform_data, deprecated_attribute + ) + assert ( + f"The deprecated function {deprecated_attribute} was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + f"{deprecated_attribute} instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_deprecated_async_load_translations( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting the device name based on input info.""" + + platform = MockPlatform() + entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) + + await entity_platform.async_load_translations() + assert ( + "The deprecated function async_load_translations was called from " + "my_integration. It will be removed in HA Core 2026.8. Use platform_data." + "async_load_translations instead, please report it to the author of the " + "'my_integration' custom integration" in caplog.text + ) From e5f9788d24ef05cc960a4f436f2419d02f4b30e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 22 Jul 2025 14:15:56 +0100 Subject: [PATCH 0501/1118] Refactor cloud backup agent to use updated file handling methods (#149231) --- homeassistant/components/cloud/backup.py | 21 ++--- tests/components/cloud/test_backup.py | 101 +++++++++++------------ 2 files changed, 53 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f4426eabeed..bca65a68abd 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -10,14 +10,8 @@ import random from typing import Any from aiohttp import ClientError, ClientResponseError -from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError -from hass_nabucasa.cloud_api import ( - FilesHandlerListEntry, - async_files_delete_file, - async_files_list, -) -from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 +from hass_nabucasa import Cloud, CloudApiError, CloudApiNonRetryableError, CloudError +from hass_nabucasa.files import FilesError, StorageType, StoredFile, calculate_b64md5 from homeassistant.components.backup import ( AgentBackup, @@ -186,8 +180,7 @@ class CloudBackupAgent(BackupAgent): """ backup = await self._async_get_backup(backup_id) try: - await async_files_delete_file( - self._cloud, + await self._cloud.files.delete( storage_type=StorageType.BACKUP, filename=backup["Key"], ) @@ -199,12 +192,10 @@ class CloudBackupAgent(BackupAgent): backups = await self._async_list_backups() return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] - async def _async_list_backups(self) -> list[FilesHandlerListEntry]: + async def _async_list_backups(self) -> list[StoredFile]: """List backups.""" try: - backups = await async_files_list( - self._cloud, storage_type=StorageType.BACKUP - ) + backups = await self._cloud.files.list(storage_type=StorageType.BACKUP) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err @@ -220,7 +211,7 @@ class CloudBackupAgent(BackupAgent): backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: + async def _async_get_backup(self, backup_id: str) -> StoredFile: """Return a backup.""" backups = await self._async_list_backups() diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 72640ed0a0e..df46102d03d 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import ANY, Mock, PropertyMock, patch +from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError @@ -48,62 +48,56 @@ async def setup_integration( @pytest.fixture -def mock_delete_file() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_delete_file", - spec_set=True, - ) as delete_file: - yield delete_file +def mock_delete_file(cloud: MagicMock) -> Generator[AsyncMock]: + """Mock delete files.""" + cloud.files.delete = AsyncMock() + return cloud.files.delete @pytest.fixture -def mock_list_files() -> Generator[MagicMock]: +def mock_list_files(cloud: MagicMock) -> Generator[MagicMock]: """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_list", spec_set=True - ) as list_files: - list_files.return_value = [ - { - "Key": "462e16810d6841228828d9dd2f9e341e.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aec", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + cloud.files.list.return_value = [ + { + "Key": "462e16810d6841228828d9dd2f9e341e.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - { - "Key": "462e16810d6841228828d9dd2f9e341f.tar", - "LastModified": "2024-11-22T10:49:01.182Z", - "Size": 34519040, - "Metadata": { - "addons": [], - "backup_id": "23e64aed", - "date": "2024-11-22T11:48:48.727189+01:00", - "database_included": True, - "extra_metadata": {}, - "folders": [], - "homeassistant_included": True, - "homeassistant_version": "2024.12.0.dev0", - "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "storage-type": "backup", - }, + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "storage-type": "backup", }, - ] - yield list_files + }, + ] + return cloud.files.list @pytest.fixture @@ -141,7 +135,7 @@ async def test_agents_list_backups( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/info"}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -250,7 +244,7 @@ async def test_agents_get_backup( client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) response = await client.receive_json() - mock_list_files.assert_called_once_with(cloud, storage_type="backup") + mock_list_files.assert_called_once_with(storage_type="backup") assert response["success"] assert response["result"]["agent_errors"] == {} @@ -726,7 +720,6 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} mock_delete_file.assert_called_once_with( - cloud, filename="462e16810d6841228828d9dd2f9e341e.tar", storage_type=StorageType.BACKUP, ) From 3947569132503fd1bba6c6247852afa33600d6b5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 22 Jul 2025 15:50:38 +0200 Subject: [PATCH 0502/1118] Bump holidays to 0.77 (#149246) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index e39525563e9..05cdd2738b6 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.76", "babel==2.15.0"] + "requirements": ["holidays==0.77", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 86c0884ee9d..32edd5d3f6a 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.76"] + "requirements": ["holidays==0.77"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47e753be1f3..69385af0e47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.76 +holidays==0.77 # homeassistant.components.frontend home-assistant-frontend==20250702.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74eb8270b5..c23ba5f4d18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.76 +holidays==0.77 # homeassistant.components.frontend home-assistant-frontend==20250702.3 From 828a47db065ca828a34c0bf8150c1505a78500d4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 22 Jul 2025 16:09:11 +0200 Subject: [PATCH 0503/1118] Add Z-Wave USB migration confirm step (#149243) --- homeassistant/components/zwave_js/config_flow.py | 15 ++++++++++++++- homeassistant/components/zwave_js/strings.json | 4 ++++ tests/components/zwave_js/test_config_flow.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 3e46fc6bac3..d98dcf3dac8 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -494,10 +494,23 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._usb_discovery = True if current_config_entries: - return await self.async_step_intent_migrate() + return await self.async_step_confirm_usb_migration() return await self.async_step_installation_type() + async def async_step_confirm_usb_migration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm USB migration.""" + if user_input is not None: + return await self.async_step_intent_migrate() + return self.async_show_form( + step_id="confirm_usb_migration", + description_placeholders={ + "usb_title": self.context["title_placeholders"][CONF_NAME], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 7f59e640ef8..4d68aa2bcbc 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,6 +108,10 @@ "start_addon": { "title": "Configuring add-on" }, + "confirm_usb_migration": { + "description": "You are about to migrate your Z-Wave network from the old adapter to the new adapter {usb_title}. This will take a backup of the network from the old adapter and restore the network to the new adapter.\n\nPress Submit to continue with the migration.", + "title": "Migrate to a new adapter" + }, "zeroconf_confirm": { "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", "title": "Discovered Z-Wave Server" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a1642746d03..c708b1c9d66 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -932,6 +932,11 @@ async def test_usb_discovery_migration( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -1049,6 +1054,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" From 969ad232aaa92e8700a47c2375a876a9c1b637ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 22 Jul 2025 18:23:38 +0200 Subject: [PATCH 0504/1118] Update aioairzone-cloud to v0.6.16 (#149254) --- homeassistant/components/airzone_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/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 8694d3d06d9..41a823386e1 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.15"] + "requirements": ["aioairzone-cloud==0.6.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 69385af0e47..db43e86000c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.15 +aioairzone-cloud==0.6.16 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c23ba5f4d18..5ce409745b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.15 +aioairzone-cloud==0.6.16 # homeassistant.components.airzone aioairzone==1.0.0 From 252a46d1410a6a4bbc8aeda29f2d856d11d7d31d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:05:54 +0200 Subject: [PATCH 0505/1118] Use translation_placeholders in tuya select descriptions (#149251) --- homeassistant/components/tuya/select.py | 15 ++++++++++----- homeassistant/components/tuya/strings.json | 12 ++---------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 22229b3f6bf..296a5e3cc2c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -320,17 +320,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_3, entity_category=EntityCategory.CONFIG, - translation_key="led_type_3", + translation_key="indexed_led_type", + translation_placeholders={"index": "3"}, ), ), # Dimmer @@ -339,12 +342,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="indexed_led_type", + translation_placeholders={"index": "1"}, ), SelectEntityDescription( key=DPCode.LED_TYPE_2, entity_category=EntityCategory.CONFIG, - translation_key="led_type_2", + translation_key="indexed_led_type", + translation_placeholders={"index": "2"}, ), ), } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 799d57547b2..6a7f6433d03 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -296,16 +296,8 @@ "led": "LED" } }, - "led_type_2": { - "name": "Light 2 source type", - "state": { - "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", - "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", - "led": "[%key:component::tuya::entity::select::led_type::state::led%]" - } - }, - "led_type_3": { - "name": "Light 3 source type", + "indexed_led_type": { + "name": "Light {index} source type", "state": { "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", From 316ac6253bfc0132220d39df5d8c78a972e26e4c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:06:14 +0200 Subject: [PATCH 0506/1118] Use translation_placeholders in tuya number descriptions (#149250) --- homeassistant/components/tuya/number.py | 30 ++++++++++++++-------- homeassistant/components/tuya/strings.json | 14 +++------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 415299307e3..383ece6eaee 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -266,32 +266,38 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgkg": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_3, - translation_key="minimum_brightness_3", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_3, - translation_key="maximum_brightness_3", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), ), @@ -300,22 +306,26 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgq": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - translation_key="minimum_brightness", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - translation_key="maximum_brightness", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - translation_key="minimum_brightness_2", + translation_key="indexed_minimum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - translation_key="maximum_brightness_2", + translation_key="indexed_maximum_brightness", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), ), diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 6a7f6433d03..a797b9e6637 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -199,17 +199,11 @@ "maximum_brightness": { "name": "Maximum brightness" }, - "minimum_brightness_2": { - "name": "Minimum brightness 2" + "indexed_minimum_brightness": { + "name": "Minimum brightness {index}" }, - "maximum_brightness_2": { - "name": "Maximum brightness 2" - }, - "minimum_brightness_3": { - "name": "Minimum brightness 3" - }, - "maximum_brightness_3": { - "name": "Maximum brightness 3" + "indexed_maximum_brightness": { + "name": "Maximum brightness {index}" }, "move_down": { "name": "Move down" From ef3fb50018fa1f009b3a8e70910fa89401c1d0b3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:44:51 +0200 Subject: [PATCH 0507/1118] Use translation_placeholders in tuya light descriptions (#149249) --- homeassistant/components/tuya/light.py | 21 ++++++++++++++------- homeassistant/components/tuya/strings.json | 7 ++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 698ca302310..cb7555c38d8 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -121,7 +121,8 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { # Based on multiple reports: manufacturer customized Dimmer 2 switches TuyaLightEntityDescription( key=DPCode.SWITCH_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -149,7 +150,8 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -313,21 +315,24 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "tgkg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, brightness_max=DPCode.BRIGHTNESS_MAX_2, brightness_min=DPCode.BRIGHTNESS_MIN_2, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_3, - translation_key="light_3", + translation_key="indexed_light", + translation_placeholders={"index": "3"}, brightness=DPCode.BRIGHT_VALUE_3, brightness_max=DPCode.BRIGHTNESS_MAX_3, brightness_min=DPCode.BRIGHTNESS_MIN_3, @@ -345,12 +350,14 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - translation_key="light", + translation_key="indexed_light", + translation_placeholders={"index": "1"}, brightness=DPCode.BRIGHT_VALUE_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - translation_key="light_2", + translation_key="indexed_light", + translation_placeholders={"index": "2"}, brightness=DPCode.BRIGHT_VALUE_2, ), ), diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a797b9e6637..169a2a4b81f 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -131,11 +131,8 @@ "light": { "name": "[%key:component::light::title%]" }, - "light_2": { - "name": "Light 2" - }, - "light_3": { - "name": "Light 3" + "indexed_light": { + "name": "Light {index}" }, "night_light": { "name": "Night light" From 55ac4d88556f15da267da244c8c251a8c6311247 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 22 Jul 2025 21:17:59 +0200 Subject: [PATCH 0508/1118] Bump aioautomower to 2.0.1 (#149262) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index d747bc00094..0234ac58e39 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.0"] + "requirements": ["aioautomower==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index db43e86000c..59287662d64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.0 +aioautomower==2.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ce409745b5..509e18fbc7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.0 +aioautomower==2.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 34eb99530fe919b31ec6467af73af98f2399727e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:21:59 +0200 Subject: [PATCH 0509/1118] Use translation_placeholders in tuya cover descriptions (#149248) Co-authored-by: Simone Chemelli --- homeassistant/components/tuya/cover.py | 18 ++++++++++++------ homeassistant/components/tuya/strings.json | 17 ++++------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 205a65431dd..7f34aa367ad 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -44,21 +44,24 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { "ckmkzq": ( TuyaCoverEntityDescription( key=DPCode.SWITCH_1, - translation_key="door", + translation_key="indexed_door", + translation_placeholders={"index": "1"}, current_state=DPCode.DOORCONTACT_STATE, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_2, - translation_key="door_2", + translation_key="indexed_door", + translation_placeholders={"index": "2"}, current_state=DPCode.DOORCONTACT_STATE_2, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_3, - translation_key="door_3", + translation_key="indexed_door", + translation_placeholders={"index": "3"}, current_state=DPCode.DOORCONTACT_STATE_3, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, @@ -78,14 +81,16 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_STATE_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_3, - translation_key="curtain_3", + translation_key="indexed_curtain", + translation_placeholders={"index": "3"}, current_position=DPCode.PERCENT_STATE_3, set_position=DPCode.PERCENT_CONTROL_3, device_class=CoverDeviceClass.CURTAIN, @@ -122,7 +127,8 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - translation_key="curtain_2", + translation_key="indexed_curtain", + translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_CONTROL_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 169a2a4b81f..abcafc490f9 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -94,20 +94,11 @@ "curtain": { "name": "[%key:component::cover::entity_component::curtain::name%]" }, - "curtain_2": { - "name": "Curtain 2" + "indexed_curtain": { + "name": "Curtain {index}" }, - "curtain_3": { - "name": "Curtain 3" - }, - "door": { - "name": "[%key:component::cover::entity_component::door::name%]" - }, - "door_2": { - "name": "Door 2" - }, - "door_3": { - "name": "Door 3" + "indexed_door": { + "name": "Door {index}" } }, "event": { From 71c1837f39fca73cddf1ee000b9d77ff3efda53a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 22 Jul 2025 22:43:02 +0200 Subject: [PATCH 0510/1118] Update OpenAI title to drop "conversation" (#149263) --- homeassistant/components/openai_conversation/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 83519821f79..5a6d76a396b 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -1,6 +1,6 @@ { "domain": "openai_conversation", - "name": "OpenAI Conversation", + "name": "OpenAI", "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@balloob"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8782d5c84b4..431ece3f81a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4633,7 +4633,7 @@ "iot_class": "cloud_polling" }, "openai_conversation": { - "name": "OpenAI Conversation", + "name": "OpenAI", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" From 45dbf3ef1aa75b3028047883d2cc7379c7f6c3e5 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:50:55 +0200 Subject: [PATCH 0511/1118] Bump uiprotect to version 7.19.0 (#149266) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5beb4ca059d..2f79154e0c5 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.18.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.19.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 59287662d64..91955a85c58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.18.1 +uiprotect==7.19.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 509e18fbc7d..b37399c8959 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.18.1 +uiprotect==7.19.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 993b0bbdd76f5519eef09679460091586115f393 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:51:03 +0200 Subject: [PATCH 0512/1118] Use absolute humidity device class in HomematicIP Cloud (#148905) --- homeassistant/components/homematicip_cloud/sensor.py | 10 ++++++---- tests/components/homematicip_cloud/test_sensor.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 1ed483b86ad..588e67bac95 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -46,6 +46,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, DEGREE, LIGHT_LUX, @@ -542,7 +543,9 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity): class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP absolute humidity sensor.""" - _attr_native_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER + _attr_device_class = SensorDeviceClass.ABSOLUTE_HUMIDITY + _attr_native_unit_of_measurement = CONCENTRATION_GRAMS_PER_CUBIC_METER + _attr_suggested_unit_of_measurement = CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: @@ -550,7 +553,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): super().__init__(hap, device, post="Absolute Humidity") @property - def native_value(self) -> int | None: + def native_value(self) -> float | None: """Return the state.""" if self.functional_channel is None: return None @@ -564,8 +567,7 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity): ): return None - # Convert from g/m³ to mg/m³ - return int(float(value) * 1000) + return round(value, 3) class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity): diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 77e90ccaff6..669cbbf664f 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -776,7 +776,7 @@ async def test_hmip_absolute_humidity_sensor( hass, mock_hap, entity_id, entity_name, device_model ) - assert ha_state.state == "6098" + assert ha_state.state == "6099.0" async def test_hmip_absolute_humidity_sensor_invalid_value( From dde73c05cbcd2d63c5b532ef54780fbabcc5f964 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:06:51 +0200 Subject: [PATCH 0513/1118] Order selectors alphabetically in helper (#149269) --- homeassistant/helpers/selector.py | 222 +++++++++++++++--------------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 2429b4b23e8..ad0c909003e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -575,49 +575,6 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): return self.config["value"] -class QrErrorCorrectionLevel(StrEnum): - """Possible error correction levels for QR code selector.""" - - LOW = "low" - MEDIUM = "medium" - QUARTILE = "quartile" - HIGH = "high" - - -class QrCodeSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent a QR code selector config.""" - - data: str - scale: int - error_correction_level: QrErrorCorrectionLevel - - -@SELECTORS.register("qr_code") -class QrCodeSelector(Selector[QrCodeSelectorConfig]): - """QR code selector.""" - - selector_type = "qr_code" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Required("data"): str, - vol.Optional("scale"): int, - vol.Optional("error_correction_level"): vol.All( - vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value - ), - } - ) - - def __init__(self, config: QrCodeSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> Any: - """Validate the passed selection.""" - vol.Schema(vol.Any(str, None))(data) - return self.config["data"] - - class ConversationAgentSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a conversation agent selector config.""" @@ -872,6 +829,39 @@ class EntitySelector(Selector[EntitySelectorConfig]): return cast(list, vol.Schema([validate])(data)) # Output is a list +class FileSelectorConfig(BaseSelectorConfig): + """Class to represent a file selector config.""" + + accept: str # required + + +@SELECTORS.register("file") +class FileSelector(Selector[FileSelectorConfig]): + """Selector of a file.""" + + selector_type = "file" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept + vol.Required("accept"): str, + } + ) + + def __init__(self, config: FileSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + if not isinstance(data, str): + raise vol.Invalid("Value should be a string") + + UUID(data) + + return data + + class FloorSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an floor selector config.""" @@ -1213,6 +1203,49 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): return data +class QrErrorCorrectionLevel(StrEnum): + """Possible error correction levels for QR code selector.""" + + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + + +class QrCodeSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a QR code selector config.""" + + data: str + scale: int + error_correction_level: QrErrorCorrectionLevel + + +@SELECTORS.register("qr_code") +class QrCodeSelector(Selector[QrCodeSelectorConfig]): + """QR code selector.""" + + selector_type = "qr_code" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Required("data"): str, + vol.Optional("scale"): int, + vol.Optional("error_correction_level"): vol.All( + vol.Coerce(QrErrorCorrectionLevel), lambda val: val.value + ), + } + ) + + def __init__(self, config: QrCodeSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(vol.Any(str, None))(data) + return self.config["data"] + + select_option = vol.All( dict, vol.Schema( @@ -1295,6 +1328,41 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class StateSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent an state selector config.""" + + entity_id: str + hide_states: list[str] + + +@SELECTORS.register("state") +class StateSelector(Selector[StateSelectorConfig]): + """Selector for an entity state.""" + + selector_type = "state" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("entity_id"): cv.entity_id, + vol.Optional("hide_states"): [str], + # The attribute to filter on, is currently deliberately not + # configurable/exposed. We are considering separating state + # selectors into two types: one for state and one for attribute. + # Limiting the public use, prevents breaking changes in the future. + # vol.Optional("attribute"): str, + } + ) + + def __init__(self, config: StateSelectorConfig) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + state: str = vol.Schema(str)(data) + return state + + class StatisticSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a statistic selector config.""" @@ -1335,41 +1403,6 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] -class StateSelectorConfig(BaseSelectorConfig, total=False): - """Class to represent an state selector config.""" - - entity_id: str - hide_states: list[str] - - -@SELECTORS.register("state") -class StateSelector(Selector[StateSelectorConfig]): - """Selector for an entity state.""" - - selector_type = "state" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - vol.Optional("entity_id"): cv.entity_id, - vol.Optional("hide_states"): [str], - # The attribute to filter on, is currently deliberately not - # configurable/exposed. We are considering separating state - # selectors into two types: one for state and one for attribute. - # Limiting the public use, prevents breaking changes in the future. - # vol.Optional("attribute"): str, - } - ) - - def __init__(self, config: StateSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - state: str = vol.Schema(str)(data) - return state - - @SELECTORS.register("target") class TargetSelector(Selector[TargetSelectorConfig]): """Selector of a target value (area ID, device ID, entity ID etc). @@ -1559,39 +1592,6 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): return vol.Schema(cv.TRIGGER_SCHEMA)(data) -class FileSelectorConfig(BaseSelectorConfig): - """Class to represent a file selector config.""" - - accept: str # required - - -@SELECTORS.register("file") -class FileSelector(Selector[FileSelectorConfig]): - """Selector of a file.""" - - selector_type = "file" - - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - { - # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept - vol.Required("accept"): str, - } - ) - - def __init__(self, config: FileSelectorConfig) -> None: - """Instantiate a selector.""" - super().__init__(config) - - def __call__(self, data: Any) -> str: - """Validate the passed selection.""" - if not isinstance(data, str): - raise vol.Invalid("Value should be a string") - - UUID(data) - - return data - - dumper.add_representer( Selector, lambda dumper, value: dumper.represent_odict( From 2f6c0a1b7f28480ac236c2b484d169ea4bbea8bf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 23 Jul 2025 00:30:23 +0200 Subject: [PATCH 0514/1118] Bump aioimmich to 0.11.0 (#149272) --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 906356a4bc9..16ae1671e3a 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.10.2"] + "requirements": ["aioimmich==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91955a85c58..8544d2125e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.2 +aioimmich==0.11.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b37399c8959..097e7cbbea1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.10.2 +aioimmich==0.11.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 9fd2ad425c5f11c3369d61361a6c39a6d2fca167 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jul 2025 07:22:48 +0200 Subject: [PATCH 0515/1118] Refactor KNX UI conditional selectors and migrate store data (#146067) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/light.py | 91 ++++---- homeassistant/components/knx/manifest.json | 2 +- .../components/knx/storage/config_store.py | 19 +- homeassistant/components/knx/storage/const.py | 21 +- .../knx/storage/entity_store_schema.py | 195 ++++++++++-------- .../components/knx/storage/knx_selector.py | 26 +++ .../components/knx/storage/migration.py | 42 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/knx/conftest.py | 19 +- .../fixtures/config_store_binarysensor.json | 2 +- .../knx/fixtures/config_store_cover.json | 2 +- .../knx/fixtures/config_store_light.json | 142 +++++++++++++ .../fixtures/config_store_light_switch.json | 3 +- .../knx/fixtures/config_store_light_v1.json | 140 +++++++++++++ tests/components/knx/test_config_store.py | 48 +++++ tests/components/knx/test_light.py | 15 +- 17 files changed, 618 insertions(+), 153 deletions(-) create mode 100644 homeassistant/components/knx/storage/migration.py create mode 100644 tests/components/knx/fixtures/config_store_light.json create mode 100644 tests/components/knx/fixtures/config_store_light_v1.json diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index cbecb878e12..1ab6883a437 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -33,6 +33,7 @@ from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_ENTITY, @@ -223,7 +224,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - color_dpt = conf.get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_COLOR, CONF_GA_COLOR) return XknxLight( xknx, @@ -232,59 +233,77 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH), group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS), group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS), - group_address_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGB - else None, - group_address_rgbw=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR) - if color_dpt == LightColorMode.RGBW - else None, - group_address_hue=conf.get_write(CONF_GA_HUE), - group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE), - group_address_saturation=conf.get_write(CONF_GA_SATURATION), - group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION), - group_address_xyy_color=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, - group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR) - if color_dpt == LightColorMode.XYY - else None, + group_address_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB + else None + ), + group_address_rgbw=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_rgbw_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW + else None + ), + group_address_hue=conf.get_write(CONF_COLOR, CONF_GA_HUE), + group_address_hue_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_HUE), + group_address_saturation=conf.get_write(CONF_COLOR, CONF_GA_SATURATION), + group_address_saturation_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_SATURATION + ), + group_address_xyy_color=( + conf.get_write(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), + group_address_xyy_color_state=( + conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) + if color_dpt == LightColorMode.XYY + else None + ), group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temp, group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH), - group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS), - group_address_brightness_red_state=conf.get_state_and_passive( - CONF_GA_RED_BRIGHTNESS + group_address_switch_red=conf.get_write(CONF_COLOR, CONF_GA_RED_SWITCH), + group_address_switch_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_SWITCH ), - group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH), + group_address_brightness_red=conf.get_write(CONF_COLOR, CONF_GA_RED_BRIGHTNESS), + group_address_brightness_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_BRIGHTNESS + ), + group_address_switch_green=conf.get_write(CONF_COLOR, CONF_GA_GREEN_SWITCH), group_address_switch_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_SWITCH + CONF_COLOR, CONF_GA_GREEN_SWITCH ), group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), group_address_brightness_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_BRIGHTNESS + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_blue_state=conf.get_state_and_passive( - CONF_GA_BLUE_BRIGHTNESS + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), - group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH), + group_address_switch_white=conf.get_write(CONF_COLOR, CONF_GA_WHITE_SWITCH), group_address_switch_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_SWITCH + CONF_COLOR, CONF_GA_WHITE_SWITCH + ), + group_address_brightness_white=conf.get_write( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), - group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS), group_address_brightness_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_BRIGHTNESS + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index baa830bfaa4..5145d2d22f8 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.4.1.91934" + "knx-frontend==2025.6.13.181749" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2899448a128..2e93256de47 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,10 +13,11 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN from .const import CONF_DATA +from .migration import migrate_1_to_2 _LOGGER = logging.getLogger(__name__) -STORAGE_VERSION: Final = 1 +STORAGE_VERSION: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -45,6 +46,20 @@ class PlatformControllerBase(ABC): """Update an existing entities configuration.""" +class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): + """Storage handler for KNXConfigStore.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + # version 2 introduced in 2025.8 + migrate_1_to_2(old_data) + + return old_data + + class KNXConfigStore: """Manage KNX config store data.""" @@ -56,7 +71,7 @@ class KNXConfigStore: """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 7cae0e9bbf6..78cd38c9d00 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -2,6 +2,7 @@ from typing import Final +# Common CONF_DATA: Final = "data" CONF_ENTITY: Final = "entity" CONF_DEVICE_INFO: Final = "device_info" @@ -12,10 +13,22 @@ CONF_DPT: Final = "dpt" CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" -CONF_GA_COLOR_TEMP: Final = "ga_color_temp" + +# Cover +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" + +# Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_GA_BRIGHTNESS: Final = "ga_brightness" +CONF_GA_COLOR_TEMP: Final = "ga_color_temp" +# Light/color +CONF_COLOR: Final = "color" CONF_GA_COLOR: Final = "ga_color" CONF_GA_RED_BRIGHTNESS: Final = "ga_red_brightness" CONF_GA_RED_SWITCH: Final = "ga_red_switch" @@ -27,9 +40,3 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" -CONF_GA_UP_DOWN: Final = "ga_up_down" -CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" -CONF_GA_POSITION_SET: Final = "ga_position_set" -CONF_GA_POSITION_STATE: Final = "ga_position_state" -CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 85bcbd1809f..6c41a7d29e7 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -29,6 +29,7 @@ from ..const import ( ) from ..validation import sync_state_validator from .const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, @@ -43,23 +44,20 @@ from .const import ( CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, CONF_GA_UP_DOWN, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) -from .knx_selector import GASelector +from .knx_selector import GASelector, GroupSelect BASE_ENTITY_SCHEMA = vol.All( { @@ -87,24 +85,6 @@ BASE_ENTITY_SCHEMA = vol.All( ) -def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: - """Validate group address schema or remove key if no address is set.""" - # frontend will return {key: {"write": None, "state": None}} for unused GA sets - # -> remove this entirely for optional keys - # if one GA is set, validate as usual - return { - vol.Optional(key): ga_selector, - vol.Remove(key): vol.Schema( - { - vol.Optional(CONF_GA_WRITE): None, - vol.Optional(CONF_GA_STATE): None, - vol.Optional(CONF_GA_PASSIVE): vol.IsFalse(), # None or empty list - }, - extra=vol.ALLOW_EXTRA, - ), - } - - BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -134,16 +114,14 @@ COVER_SCHEMA = vol.Schema( vol.Required(DOMAIN): vol.All( vol.Schema( { - **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), - **optional_ga_schema( - CONF_GA_POSITION_STATE, GASelector(write=False) - ), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CONF_GA_ANGLE): GASelector(), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), vol.Optional( CoverConf.TRAVELLING_TIME_DOWN, default=25 @@ -208,72 +186,111 @@ class LightColorModeSchema(StrEnum): HSV = "hsv" -_LIGHT_COLOR_MODE_SCHEMA = "_light_color_mode_schema" +_hs_color_inclusion_msg = ( + "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" +) -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - **optional_ga_schema( - CONF_GA_COLOR_TEMP, GASelector(write_required=True, dpt=ColorTempModes) + +LIGHT_KNX_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + vol.Optional(CONF_GA_COLOR_TEMP): GASelector( + write_required=True, dpt=ColorTempModes + ), + vol.Optional(CONF_COLOR): GroupSelect( + vol.Schema( + { + vol.Optional(CONF_GA_COLOR): GASelector( + write_required=True, dpt=LightColorMode + ) + } + ), + vol.Schema( + { + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_RED_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( + write_required=False + ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( + write_required=False + ), + } + ), + vol.Schema( + { + vol.Required(CONF_GA_HUE): GASelector(write_required=True), + vol.Required(CONF_GA_SATURATION): GASelector( + write_required=True + ), + } + ), + # msg="error in `color` config", + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ), + vol.Any( + vol.Schema( + {vol.Required(CONF_GA_SWITCH): object}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_COLOR): {vol.Required(CONF_GA_RED_BRIGHTNESS): object}}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color + { + vol.Required(CONF_GA_BRIGHTNESS, msg=_hs_color_inclusion_msg): object, + vol.Required(CONF_COLOR): { + vol.Required(CONF_GA_HUE, msg=_hs_color_inclusion_msg): object, + vol.Required( + CONF_GA_SATURATION, msg=_hs_color_inclusion_msg + ): object, + }, + }, + extra=vol.ALLOW_EXTRA, ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.DEFAULT.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema( - CONF_GA_COLOR, - GASelector(write_required=True, dpt=LightColorMode), + vol.Schema( # hs-colors not used + { + vol.Optional(CONF_COLOR): { + vol.Optional(CONF_GA_HUE): None, + vol.Optional(CONF_GA_SATURATION): None, + }, + }, + extra=vol.ALLOW_EXTRA, ), - } + msg=_hs_color_inclusion_msg, + ), ) -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema(CONF_GA_SWITCH, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_RED_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_GREEN_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BLUE_SWITCH, GASelector(write_required=False)), - **optional_ga_schema(CONF_GA_WHITE_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_WHITE_SWITCH, GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.HSV.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Required(CONF_GA_BRIGHTNESS): GASelector(write_required=True), - vol.Required(CONF_GA_HUE): GASelector(write_required=True), - vol.Required(CONF_GA_SATURATION): GASelector(write_required=True), - } -) - - -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - _LIGHT_COLOR_MODE_SCHEMA, - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) LIGHT_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index a1510dbb384..fe909f1fd0a 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,5 +1,6 @@ """Selectors for KNX.""" +from collections.abc import Hashable, Iterable from enum import Enum from typing import Any @@ -9,6 +10,31 @@ from ..validation import ga_validator, maybe_ga_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +class GroupSelect(vol.Any): + """Use the first validated value. + + This is a version of vol.Any with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any: + """Execute the validation functions.""" + errors: list[vol.Invalid] = [] + for func in funcs: + try: + if path is None: + return func(v) + return func(path, v) + except vol.Invalid as e: + errors.append(e) + if errors: + raise next( + (err for err in errors if "extra keys not allowed" not in err.msg), + errors[0], + ) + raise vol.AnyInvalid(self.msg or "no valid value found", path=path) + + class GASelector: """Selector for a KNX group address structure.""" diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py new file mode 100644 index 00000000000..f7d7941e5cc --- /dev/null +++ b/homeassistant/components/knx/storage/migration.py @@ -0,0 +1,42 @@ +"""Migration functions for KNX config store schema.""" + +from typing import Any + +from homeassistant.const import Platform + +from . import const as store_const + + +def migrate_1_to_2(data: dict[str, Any]) -> None: + """Migrate from schema 1 to schema 2.""" + if lights := data.get("entities", {}).get(Platform.LIGHT): + for light in lights.values(): + _migrate_light_schema_1_to_2(light["knx"]) + + +def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: + """Migrate light color mode schema.""" + # Remove no more needed helper data from schema + light_knx_data.pop("_light_color_mode_schema", None) + + # Move color related group addresses to new "color" key + color: dict[str, Any] = {} + for color_key in ( + # optional / required and exclusive keys are the same in old and new schema + store_const.CONF_GA_COLOR, + store_const.CONF_GA_HUE, + store_const.CONF_GA_SATURATION, + store_const.CONF_GA_RED_BRIGHTNESS, + store_const.CONF_GA_RED_SWITCH, + store_const.CONF_GA_GREEN_BRIGHTNESS, + store_const.CONF_GA_GREEN_SWITCH, + store_const.CONF_GA_BLUE_BRIGHTNESS, + store_const.CONF_GA_BLUE_SWITCH, + store_const.CONF_GA_WHITE_BRIGHTNESS, + store_const.CONF_GA_WHITE_SWITCH, + ): + if color_key in light_knx_data: + color[color_key] = light_knx_data.pop(color_key) + + if color: + light_knx_data[store_const.CONF_COLOR] = color diff --git a/requirements_all.txt b/requirements_all.txt index 8544d2125e2..c6b57127837 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 097e7cbbea1..20500240a5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1126,7 +1126,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.4.1.91934 +knx-frontend==2025.6.13.181749 # homeassistant.components.konnected konnected==1.2.0 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 32f7745a6e0..576fce802c0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -76,6 +76,7 @@ class KNXTestKit: yaml_config: ConfigType | None = None, config_store_fixture: str | None = None, add_entry_to_hass: bool = True, + state_updater: bool = True, ) -> None: """Create the KNX integration.""" @@ -118,14 +119,24 @@ class KNXTestKit: self.mock_config_entry.add_to_hass(self.hass) knx_config = {DOMAIN: yaml_config or {}} - with patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, + with ( + patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ), ): + state_updater_patcher = patch( + "xknx.xknx.StateUpdater.register_remote_value" + ) + if not state_updater: + state_updater_patcher.start() + await async_setup_component(self.hass, DOMAIN, knx_config) await self.hass.async_block_till_done() + state_updater_patcher.stop() + ######################## # Telegram counter tests ######################## diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 427867cff8c..2b6e5887f9e 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_cover.json b/tests/components/knx/fixtures/config_store_cover.json index 6ec8dcc90fa..8f89a4ee47b 100644 --- a/tests/components/knx/fixtures/config_store_cover.json +++ b/tests/components/knx/fixtures/config_store_cover.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json new file mode 100644 index 00000000000..61ec1044746 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light.json @@ -0,0 +1,142 @@ +{ + "version": 2, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + } + } + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000, + "color": { + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + } + } + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store_light_switch.json b/tests/components/knx/fixtures/config_store_light_switch.json index 5eabcfa87f9..0b14535bbea 100644 --- a/tests/components/knx/fixtures/config_store_light_switch.json +++ b/tests/components/knx/fixtures/config_store_light_switch.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "minor_version": 1, "key": "knx/config_store.json", "data": { @@ -33,7 +33,6 @@ "knx": { "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/21", "state": "1/0/21", diff --git a/tests/components/knx/fixtures/config_store_light_v1.json b/tests/components/knx/fixtures/config_store_light_v1.json new file mode 100644 index 00000000000..3e049e145f2 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_light_v1.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": { + "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": { + "entity": { + "name": "rgbw", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/0/1", + "state": "1/0/0", + "passive": [] + }, + "ga_brightness": { + "write": "1/1/1", + "state": "1/1/0", + "passive": [] + }, + "ga_color": { + "write": "1/2/1", + "dpt": "251.600", + "state": "1/2/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": { + "entity": { + "name": "individual colors", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "individual", + "ga_red_switch": { + "write": "2/1/1", + "state": null, + "passive": [] + }, + "ga_red_brightness": { + "write": "2/1/2", + "state": null, + "passive": [] + }, + "ga_green_switch": { + "write": "2/2/1", + "state": null, + "passive": [] + }, + "ga_green_brightness": { + "write": "2/2/2", + "state": null, + "passive": [] + }, + "ga_blue_switch": { + "write": "2/3/1", + "state": null, + "passive": [] + }, + "ga_blue_brightness": { + "write": "2/3/2", + "state": null, + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFMSYYRDBDJYJR1K29ABEE": { + "entity": { + "name": "hsv", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "hsv", + "ga_switch": { + "write": "3/0/1", + "state": null, + "passive": [] + }, + "ga_brightness": { + "write": "3/1/1", + "state": null, + "passive": [] + }, + "ga_hue": { + "write": "3/2/1", + "state": "3/2/0", + "passive": [] + }, + "ga_saturation": { + "write": "3/3/1", + "state": "3/3/0", + "passive": [] + }, + "sync_state": true, + "color_temp_min": 2700, + "color_temp_max": 6000 + } + }, + "knx_es_01JWDFP1RH50JXP5D2SSSRKWWT": { + "entity": { + "name": "ct", + "device_info": null, + "entity_category": null + }, + "knx": { + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "4/0/1", + "state": "4/0/0", + "passive": [] + }, + "ga_color_temp": { + "write": "4/1/1", + "dpt": "7.600", + "state": "4/1/0", + "passive": [] + }, + "color_temp_max": 4788, + "sync_state": true, + "color_temp_min": 2700 + } + } + } + } + } +} diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index aee0a4036ff..3e902f8f402 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from . import KnxEntityGenerator from .conftest import KNXTestKit +from tests.common import async_load_json_object_fixture from tests.typing import WebSocketGenerator @@ -379,6 +380,7 @@ async def test_validate_entity( await knx.setup_integration() client = await hass_ws_client(hass) + # valid data await client.send_json_auto_id( { "type": "knx/validate_entity", @@ -410,3 +412,49 @@ async def test_validate_entity( assert res["result"]["errors"][0]["path"] == ["data", "knx", "ga_switch", "write"] assert res["result"]["errors"][0]["error_message"] == "required key not provided" assert res["result"]["error_base"].startswith("required key not provided") + + # invalid group_select data + await client.send_json_auto_id( + { + "type": "knx/validate_entity", + "platform": Platform.LIGHT, + "data": { + "entity": {"name": "test_name"}, + "knx": { + "color": { + "ga_red_brightness": {"write": "1/2/3"}, + "ga_green_brightness": {"write": "1/2/4"}, + # ga_blue_brightness is missing - which is required + } + }, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + # This shall test that a required key of the second GroupSelect schema is missing + # and not yield the "extra keys not allowed" error of the first GroupSelect Schema + assert res["result"]["errors"][0]["path"] == [ + "data", + "knx", + "color", + "ga_blue_brightness", + ] + assert res["result"]["errors"][0]["error_message"] == "required key not provided" + assert res["result"]["error_base"].startswith("required key not provided") + + +async def test_migration_1_to_2( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +) -> None: + """Test migration from schema 1 to schema 2.""" + await knx.setup_integration( + config_store_fixture="config_store_light_v1.json", state_updater=False + ) + new_data = await async_load_json_object_fixture( + hass, "config_store_light.json", "knx" + ) + assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index fb0246763a4..5edf150ef4f 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1182,7 +1182,6 @@ async def test_light_ui_create( entity_data={"name": "test"}, knx_data={ "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1223,7 +1222,6 @@ async def test_light_ui_color_temp( "write": "3/3/3", "dpt": color_temp_mode, }, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1257,7 +1255,6 @@ async def test_light_ui_multi_mode( knx_data={ "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/1", "passive": [], @@ -1275,11 +1272,13 @@ async def test_light_ui_multi_mode( "state": "0/6/3", "passive": [], }, - "ga_color": { - "write": "0/6/4", - "dpt": "251.600", - "state": "0/6/5", - "passive": [], + "color": { + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, }, }, ) From 5f2f0386093f3513300d85f097089207ec8edbca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 22 Jul 2025 20:14:41 -1000 Subject: [PATCH 0516/1118] Bump dbus-fast to 2.44.2 (#149281) --- homeassistant/components/bluetooth/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/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cf3ee8e0db9..3b1e6e70ff6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", - "dbus-fast==2.43.0", + "dbus-fast==2.44.2", "habluetooth==4.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa0e1768d52..9f0e0408efd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==45.0.3 -dbus-fast==2.43.0 +dbus-fast==2.44.2 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index c6b57127837..479e5598aaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -762,7 +762,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20500240a5e..eae65039b8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ datadog==0.15.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.43.0 +dbus-fast==2.44.2 # homeassistant.components.debugpy debugpy==1.8.14 From 40571dff3d4f41b440b230b45039fd6a66927cb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 09:33:27 +0200 Subject: [PATCH 0517/1118] Replace typo "effect" with "affect" in `insteon` (#149292) --- homeassistant/components/insteon/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 3a15d667ca7..dedbc9c4fa9 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -18,16 +18,16 @@ } }, "hubv1": { - "title": "Insteon Hub Version 1", - "description": "Configure the Insteon Hub Version 1 (pre-2014).", + "title": "Insteon Hub version 1", + "description": "Configure the Insteon Hub version 1 (pre-2014).", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "hubv2": { - "title": "Insteon Hub Version 2", - "description": "Configure the Insteon Hub Version 2.", + "title": "Insteon Hub version 2", + "description": "Configure the Insteon Hub version 2.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -144,7 +144,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." + "description": "If enabled, all current records are cleared from memory (does not affect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." } } }, From a5ab52301494aa152674cf974a5831c3296f78c1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:03:04 +0200 Subject: [PATCH 0518/1118] Fix sentence-casing in `tomorrowio` (#149293) --- homeassistant/components/tomorrowio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index c3f52155d29..033b338f1a4 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -23,10 +23,10 @@ "options": { "step": { "init": { - "title": "Update Tomorrow.io Options", + "title": "Update Tomorrow.io options", "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", "data": { - "timestep": "Min. Between NowCast Forecasts" + "timestep": "Minutes between NowCast forecasts" } } } From 9a6ba225e4887d7f7871d5a49334f05452e65fbf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 10:33:31 +0200 Subject: [PATCH 0519/1118] Fix typo "paela" in `miele` (#149295) --- homeassistant/components/miele/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 865f3313ad5..18893c238fe 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -698,7 +698,7 @@ "parsnip_cut_into_batons": "Parsnip (cut into batons)", "parsnip_diced": "Parsnip (diced)", "parsnip_sliced": "Parsnip (sliced)", - "pasta_paela": "Pasta/Paela", + "pasta_paela": "Pasta/paella", "pears_halved": "Pears (halved)", "pears_quartered": "Pears (quartered)", "pears_to_cook_large_halved": "Pears to cook (large, halved)", From 51a46a128c4544a2fc78904e617615a7e67cddda Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 23 Jul 2025 10:46:52 +0200 Subject: [PATCH 0520/1118] Begin migrating unifiprotect to use the public API (#149126) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/__init__.py | 43 ++++- .../components/unifiprotect/config_flow.py | 20 +++ .../components/unifiprotect/const.py | 2 +- .../components/unifiprotect/strings.json | 30 +++- .../components/unifiprotect/utils.py | 3 + tests/components/unifiprotect/conftest.py | 2 + .../unifiprotect/fixtures/sample_nvr.json | 2 +- .../unifiprotect/test_config_flow.py | 144 +++++++++++++-- tests/components/unifiprotect/test_init.py | 165 ++++++++++++++++++ .../unifiprotect/test_media_source.py | 1 + 10 files changed, 380 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 440250d45a3..5fa9a85d341 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -16,9 +16,13 @@ from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -33,7 +37,6 @@ from .const import ( DEVICES_THAT_ADOPT, DOMAIN, MIN_REQUIRED_PROTECT_V, - OUTDATED_LOG_MESSAGE, PLATFORMS, ) from .data import ProtectData, UFPConfigEntry @@ -69,6 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" + protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") @@ -89,6 +93,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: bootstrap = protect.bootstrap nvr_info = bootstrap.nvr auth_user = bootstrap.users.get(bootstrap.auth_user_id) + + # Check if API key is missing + if not protect.is_api_key_set() and auth_user and nvr_info.can_write(auth_user): + try: + new_api_key = await protect.create_api_key( + name=f"Home Assistant ({hass.config.location_name})" + ) + except NotAuthorized as err: + _LOGGER.error("Failed to create API key: %s", err) + else: + protect.set_api_key(new_api_key) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_API_KEY: new_api_key} + ) + + if not protect.is_api_key_set(): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + if auth_user and auth_user.cloud_account: ir.async_create_issue( hass, @@ -103,12 +128,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: ) if nvr_info.version < MIN_REQUIRED_PROTECT_V: - _LOGGER.error( - OUTDATED_LOG_MESSAGE, - nvr_info.version, - MIN_REQUIRED_PROTECT_V, + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="protect_version", + translation_placeholders={ + "current_version": str(nvr_info.version), + "min_version": str(MIN_REQUIRED_PROTECT_V), + }, ) - return False if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index c83b3f11010..0eab326d609 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import ( OptionsFlowWithReload, ) from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_ID, CONF_PASSWORD, @@ -214,6 +215,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -247,6 +249,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): session = async_create_clientsession( self.hass, cookie_jar=CookieJar(unsafe=True) ) + public_api_session = async_get_clientsession(self.hass) host = user_input[CONF_HOST] port = user_input.get(CONF_PORT, DEFAULT_PORT) @@ -254,10 +257,12 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): protect = ProtectApiClient( session=session, + public_api_session=public_api_session, host=host, port=port, username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], + api_key=user_input[CONF_API_KEY], verify_ssl=verify_ssl, cache_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), config_dir=Path(self.hass.config.path(STORAGE_DIR, "unifiprotect")), @@ -286,6 +291,14 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): auth_user = bootstrap.users.get(bootstrap.auth_user_id) if auth_user and auth_user.cloud_account: errors["base"] = "cloud_user" + try: + await protect.get_meta_info() + except NotAuthorized as ex: + _LOGGER.debug(ex) + errors[CONF_API_KEY] = "invalid_auth" + except ClientError as ex: + _LOGGER.error(ex) + errors["base"] = "cannot_connect" return nvr_data, errors @@ -318,12 +331,18 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): } return self.async_show_form( step_id="reauth_confirm", + description_placeholders={ + "local_user_documentation_url": await async_local_user_documentation_url( + self.hass + ), + }, data_schema=vol.Schema( { vol.Required( CONF_USERNAME, default=form_data.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, @@ -366,6 +385,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_USERNAME, default=user_input.get(CONF_USERNAME) ): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, } ), errors=errors, diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index d041b713125..f7138c24341 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,7 +52,7 @@ DEVICES_THAT_ADOPT = { DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} -MIN_REQUIRED_PROTECT_V = Version("1.20.0") +MIN_REQUIRED_PROTECT_V = Version("6.0.0") OUTDATED_LOG_MESSAGE = ( "You are running v%s of UniFi Protect. Minimum required version is v%s. Please" " upgrade UniFi Protect and then retry" diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 23c662f5d71..f20b56d29e4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -10,19 +10,27 @@ "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "host": "Hostname or IP address of your UniFi Protect device." + "host": "Hostname or IP address of your UniFi Protect device.", + "api_key": "API key for your local user account." } }, "reauth_confirm": { "title": "UniFi Protect reauth", + "description": "Your credentials or API key seem to be missing or invalid. For instructions on how to create a local user or generate a new API key, please refer to the documentation: {local_user_documentation_url}", "data": { "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account.", + "username": "Username for your local (not cloud) user account." } }, "discovery_confirm": { @@ -30,14 +38,18 @@ "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API key for your local user account." } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", + "protect_version": "Minimum required version is v6.0.0. Please upgrade UniFi Protect and then retry.", "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { @@ -669,5 +681,13 @@ } } } + }, + "exceptions": { + "api_key_required": { + "message": "API key is required. Please reauthenticate this integration to provide an API key." + }, + "protect_version": { + "message": "Your UniFi Protect version ({current_version}) is too old. Minimum required: {min_version}." + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 61314346d32..9071a24eae6 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -110,13 +110,16 @@ def async_create_api_client( """Create ProtectApiClient from config entry.""" session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + public_api_session = async_create_clientsession(hass) return ProtectApiClient( host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + api_key=entry.data.get("api_key"), verify_ssl=entry.data[CONF_VERIFY_SSL], session=session, + public_api_session=public_api_session, subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index c49ade514bc..895ba62f81a 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -32,6 +32,7 @@ from uiprotect.data import ( from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -68,6 +69,7 @@ def mock_ufp_config_entry(): "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 13e93a8c2e7..dc841ab7a1e 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -5,7 +5,7 @@ "canAutoUpdate": true, "isStatsGatheringEnabled": true, "timezone": "America/New_York", - "version": "2.2.6", + "version": "6.0.0", "ucoreVersion": "2.3.26", "firmwareVersion": "2.3.10", "uiVersion": null, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 880578719cd..a5cda887b4d 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -74,6 +74,10 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -89,6 +93,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -99,6 +104,7 @@ async def test_form(hass: HomeAssistant, bootstrap: Bootstrap, nvr: NVR) -> None "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -116,9 +122,15 @@ async def test_form_version_too_old( ) bootstrap.nvr = old_nvr - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -126,6 +138,7 @@ async def test_form_version_too_old( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -133,15 +146,21 @@ async def test_form_version_too_old( assert result2["errors"] == {"base": "protect_version"} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +async def test_form_invalid_auth_password(hass: HomeAssistant) -> None: + """Test we handle invalid auth password.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -149,6 +168,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -156,6 +176,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"password": "invalid_auth"} +async def test_form_invalid_auth_api_key( + hass: HomeAssistant, bootstrap: Bootstrap +) -> None: + """Test we handle invalid auth api key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NotAuthorized, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"api_key": "invalid_auth"} + + async def test_form_cloud_user( hass: HomeAssistant, bootstrap: Bootstrap, cloud_account: CloudAccount ) -> None: @@ -167,9 +219,15 @@ async def test_form_cloud_user( user = bootstrap.users[bootstrap.auth_user_id] user.cloud_account = cloud_account bootstrap.users[bootstrap.auth_user_id] = user - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - return_value=bootstrap, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + return_value=bootstrap, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -177,6 +235,7 @@ async def test_form_cloud_user( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -190,9 +249,15 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NvrError, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NvrError, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + side_effect=NvrError, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -200,6 +265,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -217,6 +283,7 @@ async def test_form_reauth_auth( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -234,15 +301,22 @@ async def test_form_reauth_auth( "name": "Mock Title", } - with patch( - "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", - side_effect=NotAuthorized, + with ( + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", + side_effect=NotAuthorized, + ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) @@ -260,12 +334,17 @@ async def test_form_reauth_auth( "homeassistant.components.unifiprotect.async_setup", return_value=True, ) as mock_setup, + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { "username": "test-username", "password": "new-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -283,6 +362,7 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "host": "1.1.1.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -383,6 +463,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -397,6 +481,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -407,6 +492,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -425,6 +511,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -583,6 +670,10 @@ async def test_discovered_by_unifi_discovery( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", side_effect=[NotAuthorized, bootstrap], ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -597,6 +688,7 @@ async def test_discovered_by_unifi_discovery( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -607,6 +699,7 @@ async def test_discovered_by_unifi_discovery( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -644,6 +737,10 @@ async def test_discovered_by_unifi_discovery_partial( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -658,6 +755,7 @@ async def test_discovered_by_unifi_discovery_partial( { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -668,6 +766,7 @@ async def test_discovered_by_unifi_discovery_partial( "host": DEVICE_IP_ADDRESS, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -686,6 +785,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": DIRECT_CONNECT_DOMAIN, "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -716,6 +816,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "127.0.0.1", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -746,6 +847,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -787,6 +889,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -827,6 +930,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_bootstrap", return_value=bootstrap, ), + patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_meta_info", + return_value=None, + ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -841,6 +948,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa { "username": "test-username", "password": "test-password", + "api_key": "test-api-key", }, ) await hass.async_block_till_done() @@ -851,6 +959,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "nomatchsameip.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, @@ -869,6 +978,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "host": "y.ui.direct", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": True, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3156327f1a5..b951d95fbdc 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.unifiprotect.data import ( async_ufp_instance_for_config_entry_ids, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -29,6 +30,19 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture +def mock_user_can_write_nvr(request: pytest.FixtureRequest, ufp: MockUFPFixture): + """Fixture to mock can_write method on NVR objects with indirect parametrization.""" + can_write_result = getattr(request, "param", True) + original_can_write = ufp.api.bootstrap.nvr.can_write + mock_can_write = Mock(return_value=can_write_result) + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", mock_can_write) + try: + yield mock_can_write + finally: + object.__setattr__(ufp.api.bootstrap.nvr, "can_write", original_can_write) + + async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" @@ -68,6 +82,7 @@ async def test_setup_multiple( "host": "1.1.1.1", "username": "test-username", "password": "test-password", + CONF_API_KEY: "test-api-key", "id": "UnifiProtect", "port": 443, "verify_ssl": False, @@ -331,6 +346,112 @@ async def test_async_ufp_instance_for_config_entry_ids( assert result == expected_result +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_creates_api_key_when_missing( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key is created when missing and user has write permissions.""" + # Setup: API key is not set initially, user has write permissions + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") + + # Mock set_api_key to update is_api_key_set return value when called + def set_api_key_side_effect(key): + ufp.api.is_api_key_set.return_value = True + + ufp.api.set_api_key.side_effect = set_api_key_side_effect + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify API key was created and set + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") + + # Verify config entry was updated with new API key + assert ufp.entry.data[CONF_API_KEY] == "new-api-key-123" + assert ufp.entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [False], indirect=True) +async def test_setup_skips_api_key_creation_when_no_write_permission( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that API key creation is skipped when user has no write permissions.""" + # Setup: API key is not set, user has no write permissions + ufp.api.is_api_key_set.return_value = False + + # Should fail with auth error since no API key and can't create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_failure( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation failure.""" + # Setup: API key is not set, user has write permissions, but creation fails + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=NotAuthorized("Failed to create API key") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + +async def test_setup_with_existing_api_key( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test setup when API key is already set.""" + # Setup: API key is already set + ufp.api.is_api_key_set.return_value = True + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.LOADED + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_api_key_creation_returns_none( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling when API key creation returns None.""" + # Setup: API key is not set, creation returns None (empty response) + # set_api_key will be called with None but is_api_key_set will still be False + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock(return_value=None) + + # Should fail with auth error since API key creation returned None + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted and set_api_key was called with None + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_called_once_with(None) + + async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: """Test remove CONF_ALLOW_EA from options while migrating a 1 config entry to 2.""" with ( @@ -350,3 +471,47 @@ async def test_migrate_entry_version_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.options.get(CONF_ALLOW_EA) is None assert entry.unique_id == "123456" + + +async def test_setup_skips_api_key_creation_when_no_auth_user( + hass: HomeAssistant, ufp: MockUFPFixture +) -> None: + """Test that API key creation is skipped when auth_user is None.""" + # Setup: API key is not set, auth_user is None + ufp.api.is_api_key_set.return_value = False + + # Mock the users dictionary to return None for any user ID + with patch.dict(ufp.api.bootstrap.users, {}, clear=True): + # Should fail with auth error since no API key and no auth user to create one + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was not attempted + ufp.api.create_api_key.assert_not_called() + ufp.api.set_api_key.assert_not_called() + + +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_fails_when_api_key_still_missing_after_creation( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test that setup fails when API key is still missing after creation attempts.""" + # Setup: API key is not set and remains not set even after attempts + ufp.api.is_api_key_set.return_value = False # type: ignore[attr-defined] + ufp.api.create_api_key = AsyncMock(return_value="new-api-key-123") # type: ignore[method-assign] + ufp.api.set_api_key = Mock() # type: ignore[method-assign] # Mock this but API key still won't be "set" + + # Setup should fail since API key is still not set after creation + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + # Verify entry is in setup error state (which will trigger reauth automatically) + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted + ufp.api.create_api_key.assert_called_once_with( # type: ignore[attr-defined] + name="Home Assistant (test home)" + ) + ufp.api.set_api_key.assert_called_once_with("new-api-key-123") # type: ignore[attr-defined] diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 61f9680bdbc..02d07bb1d4d 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -234,6 +234,7 @@ async def test_browse_media_root_multiple_consoles( "host": "1.1.1.2", "username": "test-username", "password": "test-password", + "api_key": "test-api-key", "id": "UnifiProtect2", "port": 443, "verify_ssl": False, From c4d742f549bf44c60f7e60edd8b892041868bd0b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 11:01:19 +0200 Subject: [PATCH 0521/1118] Add missing hyphen to "auto-renew period" in `whois` (#149296) --- homeassistant/components/whois/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index b236bb06208..814b952d417 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -52,7 +52,7 @@ "name": "Status", "state": { "add_period": "Add period", - "auto_renew_period": "Auto renew period", + "auto_renew_period": "Auto-renew period", "inactive": "Inactive", "ok": "Active", "active": "Active", From 7aa4810b0abbab39fa0296ae10e7d8b8c24e6dee Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:26:54 +0200 Subject: [PATCH 0522/1118] Clean up internal_get_tts_audio in TTS entity (#148946) --- homeassistant/components/tts/entity.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index dc6f22570fc..aea5be6d0da 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -165,18 +165,6 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH self.async_write_ha_state() return await self.async_stream_tts_audio(request) - @final - async def async_internal_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine and update state. - - Return a tuple of file extension and data as bytes. - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio(message, language, options=options) - async def async_stream_tts_audio( self, request: TTSAudioRequest ) -> TTSAudioResponse: From 52abab8ae812854e104f007304dca77a5efa64e4 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Wed, 23 Jul 2025 11:28:28 +0200 Subject: [PATCH 0523/1118] Use translation_key for entities in Huum (#149256) --- homeassistant/components/huum/binary_sensor.py | 1 - homeassistant/components/huum/light.py | 2 +- homeassistant/components/huum/strings.json | 7 +++++++ tests/components/huum/snapshots/test_light.ambr | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py index a8e094dda94..7bc03e9fe94 100644 --- a/homeassistant/components/huum/binary_sensor.py +++ b/homeassistant/components/huum/binary_sensor.py @@ -27,7 +27,6 @@ async def async_setup_entry( class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): """Representation of a BinarySensor.""" - _attr_name = "Door" _attr_device_class = BinarySensorDeviceClass.DOOR def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/huum/light.py b/homeassistant/components/huum/light.py index 8eb35afdda2..9d3ec54101d 100644 --- a/homeassistant/components/huum/light.py +++ b/homeassistant/components/huum/light.py @@ -32,7 +32,7 @@ async def async_setup_entry( class HuumLight(HuumBaseEntity, LightEntity): """Representation of a light.""" - _attr_name = "Light" + _attr_translation_key = "light" _attr_supported_color_modes = {ColorMode.ONOFF} _attr_color_mode = ColorMode.ONOFF diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 68ab1adde6f..55ccf0fdd81 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -18,5 +18,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } } } diff --git a/tests/components/huum/snapshots/test_light.ambr b/tests/components/huum/snapshots/test_light.ambr index 918210272b2..da449c16fe8 100644 --- a/tests/components/huum/snapshots/test_light.ambr +++ b/tests/components/huum/snapshots/test_light.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'AABBCC112233', 'unit_of_measurement': None, }) From aeeabfcae726b06ee00d525f4849df41912e1ce9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 11:55:35 +0200 Subject: [PATCH 0524/1118] Fix typo "hazlenut" in `miele` (#149299) --- homeassistant/components/miele/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 18893c238fe..2ae412ed95e 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -462,8 +462,8 @@ "chicken_tikka_masala_with_rice": "Chicken Tikka Masala with rice", "chicken_whole": "Chicken", "chinese_cabbage_cut": "Chinese cabbage (cut)", - "chocolate_hazlenut_cake_one_large": "Chocolate hazlenut cake (one large)", - "chocolate_hazlenut_cake_several_small": "Chocolate hazlenut cake (several small)", + "chocolate_hazlenut_cake_one_large": "Chocolate hazelnut cake (one large)", + "chocolate_hazlenut_cake_several_small": "Chocolate hazelnut cake (several small)", "chongming_rapid_steam_cooking": "Chongming (rapid steam cooking)", "chongming_steam_cooking": "Chongming (steam cooking)", "choux_buns": "Choux buns", From 232b34609ca2306ad512b50bbd804637b8e0be94 Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Wed, 23 Jul 2025 06:37:47 -0400 Subject: [PATCH 0525/1118] Avoid hardcoded max core climate timeout in SleepIQ (#149283) --- homeassistant/components/sleepiq/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index ffbcbe7a970..1a99f47c38c 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -164,7 +164,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( key=CORE_CLIMATE_TIMER, native_min_value=0, - native_max_value=600, + native_max_value=SleepIQCoreClimate.max_core_climate_time, native_step=30, name=ENTITY_TYPES[CORE_CLIMATE_TIMER], icon="mdi:timer", From b37273ed33688258c1ef77e6e682ffc6b8c4bdc4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:39:17 +0200 Subject: [PATCH 0526/1118] Makes entites available in Husqvarna Automower when mower is in error state (#149261) --- .../components/husqvarna_automower/button.py | 9 ++---- .../components/husqvarna_automower/entity.py | 28 +++++-------------- .../husqvarna_automower/lawn_mower.py | 4 +-- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 281669aad04..8e58a309e59 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -14,11 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import ( - AutomowerAvailableEntity, - _check_error_free, - handle_sending_exception, -) +from .entity import AutomowerControlEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -45,7 +41,6 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( AutomowerButtonEntityDescription( key="sync_clock", translation_key="sync_clock", - available_fn=_check_error_free, press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), ) @@ -71,7 +66,7 @@ async def async_setup_entry( _async_add_new_devices(set(coordinator.data)) -class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): +class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): """Defining the AutomowerButtonEntity.""" entity_description: AutomowerButtonEntityDescription diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 3ccb098262f..99df51c7fe7 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -37,15 +37,6 @@ ERROR_STATES = [ ] -@callback -def _check_error_free(mower_attributes: MowerAttributes) -> bool: - """Check if the mower has any errors.""" - return ( - mower_attributes.mower.state not in ERROR_STATES - or mower_attributes.mower.activity not in ERROR_ACTIVITIES - ) - - @callback def _work_area_translation_key(work_area_id: int, key: str) -> str: """Return the translation key.""" @@ -120,25 +111,20 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): return super().available and self.mower_id in self.coordinator.data -class AutomowerAvailableEntity(AutomowerBaseEntity): +class AutomowerControlEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" @property def available(self) -> bool: """Return True if the device is available.""" - return super().available and self.mower_attributes.metadata.connected + return ( + super().available + and self.mower_attributes.metadata.connected + and self.mower_attributes.mower.state != MowerStates.OFF + ) -class AutomowerControlEntity(AutomowerAvailableEntity): - """Replies available when the mower is connected and not in error state.""" - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return super().available and _check_error_free(self.mower_attributes) - - -class WorkAreaAvailableEntity(AutomowerAvailableEntity): +class WorkAreaAvailableEntity(AutomowerControlEntity): """Base entity for work areas.""" def __init__( diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index daeb4a113b5..df312ae4ffd 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerAvailableEntity, handle_sending_exception +from .entity import AutomowerBaseEntity, handle_sending_exception _LOGGER = logging.getLogger(__name__) @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): +class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): """Defining each mower Entity.""" _attr_name = None From 3dffd74607e7712cdeecfba85a57d560adbba944 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 12:58:15 +0200 Subject: [PATCH 0527/1118] Migrate OpenAI to has entity name (#149301) --- homeassistant/components/openai_conversation/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 93713c78d9c..c1b2f970f07 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -274,11 +274,13 @@ async def _transform_stream( class OpenAIBaseLLMEntity(Entity): """OpenAI conversation agent.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, From eb8ca53a037165994d51ca0ecba8ca94d259f9b7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 12:58:28 +0200 Subject: [PATCH 0528/1118] Migrate Anthropic to has entity name (#149302) --- homeassistant/components/anthropic/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index a28c948d28b..636417dd43b 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -311,11 +311,13 @@ def _create_token_stats( class AnthropicBaseLLMEntity(Entity): """Anthropic base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, From edf6166a9f81d8569934c49c2c9a1761953cad09 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 13:58:24 +0200 Subject: [PATCH 0529/1118] Fix spelling of "Domino's Pizza" in `dominos` (#149308) --- homeassistant/components/dominos/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dominos/strings.json b/homeassistant/components/dominos/strings.json index 0ceabd7abe8..5d95be478ce 100644 --- a/homeassistant/components/dominos/strings.json +++ b/homeassistant/components/dominos/strings.json @@ -2,11 +2,11 @@ "services": { "order": { "name": "Order", - "description": "Places a set of orders with Dominos Pizza.", + "description": "Places a set of orders with Domino's Pizza.", "fields": { "order_entity_id": { "name": "Order entity", - "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed." + "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed." } } } From dcf29d12a73512aeb705d4b6336de1c9b426452e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 23 Jul 2025 14:27:32 +0200 Subject: [PATCH 0530/1118] Migrate Ollama to has entity name (#149303) --- homeassistant/components/ollama/entity.py | 4 +++- tests/components/ollama/test_conversation.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index b2f0ebbb7b8..2581698e185 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -170,11 +170,13 @@ async def _transform_stream( class OllamaBaseLLMEntity(Entity): """Ollama base LLM entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" self.entry = entry self.subentry = subentry - self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id model, _, version = subentry.data[CONF_MODEL].partition(":") diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index f7e50d61e2c..4904829a31c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -619,7 +619,6 @@ async def test_conversation_agent( assert entity_entry subentry = mock_config_entry.subentries.get(entity_entry.unique_id) assert subentry - assert entity_entry.original_name == subentry.title device_entry = device_registry.async_get(entity_entry.device_id) assert device_entry From 2a0a31bff8b2bb0b5c2703bb47f042d9be75cbac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 14:27:49 +0200 Subject: [PATCH 0531/1118] Capitalize "HEPA" as an abbreviation in `matter` (#149306) --- homeassistant/components/matter/strings.json | 2 +- tests/components/matter/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 20d7eb69ba4..7f603c9d188 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -310,7 +310,7 @@ "name": "Flow" }, "hepa_filter_condition": { - "name": "Hepa filter condition" + "name": "HEPA filter condition" }, "operational_state": { "name": "Operational state", diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 140384283cc..bff4ad7909d 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -250,7 +250,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -263,7 +263,7 @@ # name: test_sensors[air_purifier][sensor.air_purifier_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier Hepa filter condition', + 'friendly_name': 'Air Purifier HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), @@ -2985,7 +2985,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Hepa filter condition', + 'original_name': 'HEPA filter condition', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2998,7 +2998,7 @@ # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Extractor hood Hepa filter condition', + 'friendly_name': 'Mock Extractor hood HEPA filter condition', 'state_class': , 'unit_of_measurement': '%', }), From 47611619db4d1c35b09f3f2b64a08e35c60f362a Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 23 Jul 2025 22:45:50 +1000 Subject: [PATCH 0532/1118] Update Tesla OAuth Server in Tesla Fleet (#149280) --- homeassistant/components/tesla_fleet/const.py | 5 ++--- tests/components/tesla_fleet/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index d73234b1fdd..761bbebf7a8 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -14,9 +14,8 @@ CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) -CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d" -AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize" -TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token" +AUTHORIZE_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize" +TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" SCOPES = [ Scope.OPENID, diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index c51cd83ee66..a43ec14fc51 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.tesla_fleet.const import CLIENT_ID, DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,7 +28,7 @@ async def setup_platform( await async_import_client_credential( hass, DOMAIN, - ClientCredential(CLIENT_ID, "", "Home Assistant"), + ClientCredential("CLIENT_ID", "CLIENT_SECRET", "Home Assistant"), DOMAIN, ) From 6dc5c9beb7839763ec507a8fba3f1f7dba5a2d9d Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Wed, 23 Jul 2025 08:52:14 -0400 Subject: [PATCH 0533/1118] Add fan off mode to the supported fan modes to fujitsu_fglair (#149277) --- homeassistant/components/fujitsu_fglair/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index bf1df07823c..85ef119a583 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_OFF, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { + FAN_OFF: FanSpeed.QUIET, FAN_LOW: FanSpeed.LOW, FAN_MEDIUM: FanSpeed.MEDIUM, FAN_HIGH: FanSpeed.HIGH, From 4d5c1b139bc2fe6c7353189c8230132593a29b2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jul 2025 02:57:07 -1000 Subject: [PATCH 0534/1118] Consolidate REST sensor encoding tests using pytest parametrize (#149279) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/rest/test_sensor.py | 108 +++++++++++---------------- 1 file changed, 42 insertions(+), 66 deletions(-) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index b830d6b7743..7bd84bbcd70 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -144,14 +144,49 @@ async def test_setup_minimum( assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 -async def test_setup_encoding( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + ("content_text", "content_encoding", "headers", "expected_state"), + [ + # Test setup with non-utf8 encoding + pytest.param( + "tack själv", + "iso-8859-1", + None, + "tack själv", + id="simple_iso88591", + ), + # Test that configured encoding is used when no charset in Content-Type + pytest.param( + "Björk Guðmundsdóttir", + "iso-8859-1", + {"Content-Type": "text/plain"}, # No charset! + "Björk Guðmundsdóttir", + id="fallback_when_no_charset", + ), + # Test that charset in Content-Type overrides configured encoding + pytest.param( + "Björk Guðmundsdóttir", + "utf-8", + {"Content-Type": "text/plain; charset=utf-8"}, + "Björk Guðmundsdóttir", + id="charset_overrides_config", + ), + ], +) +async def test_setup_with_encoding_config( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + content_text: str, + content_encoding: str, + headers: dict[str, str] | None, + expected_state: str, ) -> None: - """Test setup with non-utf8 encoding.""" + """Test setup with encoding configuration in sensor config.""" aioclient_mock.get( "http://localhost", status=HTTPStatus.OK, - content="tack själv".encode(encoding="iso-8859-1"), + content=content_text.encode(content_encoding), + headers=headers, ) assert await async_setup_component( hass, @@ -168,10 +203,10 @@ async def test_setup_encoding( ) await hass.async_block_till_done() assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "tack själv" + assert hass.states.get("sensor.mysensor").state == expected_state -async def test_setup_auto_encoding_from_content_type( +async def test_setup_with_charset_from_header( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with encoding auto-detected from Content-Type header.""" @@ -188,7 +223,7 @@ async def test_setup_auto_encoding_from_content_type( { SENSOR_DOMAIN: { "name": "mysensor", - # encoding defaults to UTF-8, but should be ignored when charset present + # No encoding config - should use charset from header. "platform": DOMAIN, "resource": "http://localhost", "method": "GET", @@ -200,65 +235,6 @@ async def test_setup_auto_encoding_from_content_type( assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" -async def test_setup_encoding_fallback_no_charset( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that configured encoding is used when no charset in Content-Type.""" - # No charset in Content-Type header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode("iso-8859-1"), - headers={"Content-Type": "text/plain"}, # No charset! - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # This will be used as fallback - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - -async def test_setup_charset_overrides_encoding_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that charset in Content-Type overrides configured encoding.""" - # Server sends UTF-8 with correct charset header - aioclient_mock.get( - "http://localhost", - status=HTTPStatus.OK, - content="Björk Guðmundsdóttir".encode(), - headers={"Content-Type": "text/plain; charset=utf-8"}, - ) - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "name": "mysensor", - "encoding": "iso-8859-1", # Config says ISO-8859-1, but charset=utf-8 should win - "platform": DOMAIN, - "resource": "http://localhost", - "method": "GET", - } - }, - ) - await hass.async_block_till_done() - assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 - # This should work because charset=utf-8 overrides the iso-8859-1 config - assert hass.states.get("sensor.mysensor").state == "Björk Guðmundsdóttir" - - @pytest.mark.parametrize( ("ssl_cipher_list", "ssl_cipher_list_expected"), [ From 70e03cdd4ebd25c9cda6d10ab000e5df160bcb27 Mon Sep 17 00:00:00 2001 From: johanzander Date: Wed, 23 Jul 2025 15:05:19 +0200 Subject: [PATCH 0535/1118] Implements coordinator pattern for Growatt component data fetching (#143373) --- .../components/growatt_server/__init__.py | 101 +++++- .../components/growatt_server/coordinator.py | 210 +++++++++++ .../components/growatt_server/models.py | 17 + .../growatt_server/sensor/__init__.py | 335 +++--------------- 4 files changed, 372 insertions(+), 291 deletions(-) create mode 100644 homeassistant/components/growatt_server/coordinator.py create mode 100644 homeassistant/components/growatt_server/models.py diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 66df76bc6cb..39270788780 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,21 +1,104 @@ """The Growatt server PV inverter sensor integration.""" -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from collections.abc import Mapping -from .const import PLATFORMS +import growattServer + +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import ( + CONF_PLANT_ID, + DEFAULT_PLANT_ID, + DEFAULT_URL, + DEPRECATED_URLS, + LOGIN_INVALID_AUTH_CODE, + PLATFORMS, +) +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .models import GrowattRuntimeData + + +def get_device_list( + api: growattServer.GrowattApi, config: Mapping[str, str] +) -> tuple[list[dict[str, str]], str]: + """Retrieve the device list for the selected plant.""" + plant_id = config[CONF_PLANT_ID] + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + if ( + not login_response["success"] + and login_response["msg"] == LOGIN_INVALID_AUTH_CODE + ): + raise ConfigEntryError("Username, Password or URL may be incorrect!") + user_id = login_response["user"]["id"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of devices for specified plant to add sensors for. + devices = api.device_list(plant_id) + return devices, plant_id async def async_setup_entry( - hass: HomeAssistant, entry: config_entries.ConfigEntry + hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: - """Load the saved entities.""" + """Set up Growatt from a config entry.""" + config = config_entry.data + username = config[CONF_USERNAME] + url = config.get(CONF_URL, DEFAULT_URL) + + # If the URL has been deprecated then change to the default instead + if url in DEPRECATED_URLS: + url = DEFAULT_URL + new_data = dict(config_entry.data) + new_data[CONF_URL] = url + hass.config_entries.async_update_entry(config_entry, data=new_data) + + # Initialise the library with the username & a random id each time it is started + api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) + api.server_url = url + + devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + + # Create a coordinator for the total sensors + total_coordinator = GrowattCoordinator( + hass, config_entry, plant_id, "total", plant_id + ) + + # Create coordinators for each device + device_coordinators = { + device["deviceSn"]: GrowattCoordinator( + hass, config_entry, device["deviceSn"], device["deviceType"], plant_id + ) + for device in devices + if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] + } + + # Perform the first refresh for the total coordinator + await total_coordinator.async_config_entry_first_refresh() + + # Perform the first refresh for each device coordinator + for device_coordinator in device_coordinators.values(): + await device_coordinator.async_config_entry_first_refresh() + + # Store runtime data in the config entry + config_entry.runtime_data = GrowattRuntimeData( + total_coordinator=total_coordinator, + devices=device_coordinators, + ) + + # Set up all the entities + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: GrowattConfigEntry +) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py new file mode 100644 index 00000000000..a1a2fb938f0 --- /dev/null +++ b/homeassistant/components/growatt_server/coordinator.py @@ -0,0 +1,210 @@ +"""Coordinator module for managing Growatt data fetching.""" + +import datetime +import json +import logging +from typing import TYPE_CHECKING, Any + +import growattServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_URL, DOMAIN +from .models import GrowattRuntimeData + +if TYPE_CHECKING: + from .sensor.sensor_entity_description import GrowattSensorEntityDescription + +type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator to manage Growatt data fetching.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GrowattConfigEntry, + device_id: str, + device_type: str, + plant_id: str, + ) -> None: + """Initialize the coordinator.""" + self.username = config_entry.data[CONF_USERNAME] + self.password = config_entry.data[CONF_PASSWORD] + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=self.username + ) + + # Set server URL + self.api.server_url = self.url + + self.device_id = device_id + self.device_type = device_type + self.plant_id = plant_id + + # Initialize previous_values to store historical data + self.previous_values: dict[str, Any] = {} + + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} ({device_id})", + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + def _sync_update_data(self) -> dict[str, Any]: + """Update data via library synchronously.""" + _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) + + # Login in to the Growatt server + self.api.login(self.username, self.password) + + if self.device_type == "total": + total_info = self.api.plant_info(self.device_id) + del total_info["deviceList"] + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency + self.data = total_info + elif self.device_type == "inverter": + self.data = self.api.inverter_detail(self.device_id) + elif self.device_type == "tlx": + tlx_info = self.api.tlx_detail(self.device_id) + self.data = tlx_info["data"] + elif self.device_type == "storage": + storage_info_detail = self.api.storage_params(self.device_id) + storage_energy_overview = self.api.storage_energy_overview( + self.plant_id, self.device_id + ) + self.data = { + **storage_info_detail["storageDetailBean"], + **storage_energy_overview, + } + elif self.device_type == "mix": + mix_info = self.api.mix_info(self.device_id) + mix_totals = self.api.mix_totals(self.device_id, self.plant_id) + mix_system_status = self.api.mix_system_status( + self.device_id, self.plant_id + ) + mix_detail = self.api.mix_detail(self.device_id, self.plant_id) + + # Get the chart data and work out the time of the last entry + mix_chart_entries = mix_detail["chartData"] + sorted_keys = sorted(mix_chart_entries) + + # Create datetime from the latest entry + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) + mix_detail["lastdataupdate"] = datetime.datetime.combine( + date_now, + last_updated_time, # type: ignore[arg-type] + dt_util.get_default_time_zone(), + ) + + # Dashboard data for mix system + dashboard_data = self.api.dashboard_data(self.plant_id) + dashboard_values_for_mix = { + "etouser_combined": float(dashboard_data["etouser"].replace("kWh", "")) + } + self.data = { + **mix_info, + **mix_totals, + **mix_system_status, + **mix_detail, + **dashboard_values_for_mix, + } + _LOGGER.debug( + "Finished updating data for %s (%s)", + self.device_id, + self.device_type, + ) + + return self.data + + async def _async_update_data(self) -> dict[str, Any]: + """Asynchronously update data via library.""" + try: + return await self.hass.async_add_executor_job(self._sync_update_data) + except json.decoder.JSONDecodeError as err: + _LOGGER.error("Unable to fetch data from Growatt server: %s", err) + raise UpdateFailed(f"Error fetching data: {err}") from err + + def get_currency(self): + """Get the currency.""" + return self.data.get("currency") + + def get_data( + self, entity_description: "GrowattSensorEntityDescription" + ) -> str | int | float | None: + """Get the data.""" + variable = entity_description.api_key + api_value = self.data.get(variable) + previous_value = self.previous_values.get(variable) + return_value = api_value + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/growatt_server/models.py b/homeassistant/components/growatt_server/models.py new file mode 100644 index 00000000000..8c5f409616a --- /dev/null +++ b/homeassistant/components/growatt_server/models.py @@ -0,0 +1,17 @@ +"""Models for the Growatt server integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .coordinator import GrowattCoordinator + + +@dataclass +class GrowattRuntimeData: + """Runtime data for the Growatt integration.""" + + total_coordinator: GrowattCoordinator + devices: dict[str, GrowattCoordinator] diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 2794403811d..3a78f26f091 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -2,29 +2,16 @@ from __future__ import annotations -import datetime -import json import logging -import growattServer - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import ( - CONF_PLANT_ID, - DEFAULT_PLANT_ID, - DEFAULT_URL, - DEPRECATED_URLS, - DOMAIN, - LOGIN_INVALID_AUTH_CODE, -) +from ..const import DOMAIN +from ..coordinator import GrowattConfigEntry, GrowattCoordinator from .inverter import INVERTER_SENSOR_TYPES from .mix import MIX_SENSOR_TYPES from .sensor_entity_description import GrowattSensorEntityDescription @@ -34,136 +21,97 @@ from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) - - -def get_device_list(api, config): - """Retrieve the device list for the selected plant.""" - plant_id = config[CONF_PLANT_ID] - - # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - raise ConfigEntryError("Username, Password or URL may be incorrect!") - user_id = login_response["user"]["id"] - if plant_id == DEFAULT_PLANT_ID: - plant_info = api.plant_list(user_id) - plant_id = plant_info["data"][0]["plantId"] - - # Get a list of devices for specified plant to add sensors for. - devices = api.device_list(plant_id) - return [devices, plant_id] - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GrowattConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" - config = {**config_entry.data} - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - url = config.get(CONF_URL, DEFAULT_URL) - name = config[CONF_NAME] + # Use runtime_data instead of hass.data + data = config_entry.runtime_data - # If the URL has been deprecated then change to the default instead - if url in DEPRECATED_URLS: - _LOGGER.warning( - "URL: %s has been deprecated, migrating to the latest default: %s", - url, - DEFAULT_URL, - ) - url = DEFAULT_URL - config[CONF_URL] = url - hass.config_entries.async_update_entry(config_entry, data=config) + entities: list[GrowattSensor] = [] - # Initialise the library with the username & a random id each time it is started - api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) - api.server_url = url - - devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) - - probe = GrowattData(api, username, password, plant_id, "total") - entities = [ - GrowattInverter( - probe, - name=f"{name} Total", - unique_id=f"{plant_id}-{description.key}", + # Add total sensors + total_coordinator = data.total_coordinator + entities.extend( + GrowattSensor( + total_coordinator, + name=f"{config_entry.data['name']} Total", + serial_id=config_entry.data["plant_id"], + unique_id=f"{config_entry.data['plant_id']}-{description.key}", description=description, ) for description in TOTAL_SENSOR_TYPES - ] + ) - # Add sensors for each device in the specified plant. - for device in devices: - probe = GrowattData( - api, username, password, device["deviceSn"], device["deviceType"] - ) - sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = () - if device["deviceType"] == "inverter": - sensor_descriptions = INVERTER_SENSOR_TYPES - elif device["deviceType"] == "tlx": - probe.plant_id = plant_id - sensor_descriptions = TLX_SENSOR_TYPES - elif device["deviceType"] == "storage": - probe.plant_id = plant_id - sensor_descriptions = STORAGE_SENSOR_TYPES - elif device["deviceType"] == "mix": - probe.plant_id = plant_id - sensor_descriptions = MIX_SENSOR_TYPES + # Add sensors for each device + for device_sn, device_coordinator in data.devices.items(): + sensor_descriptions: list = [] + if device_coordinator.device_type == "inverter": + sensor_descriptions = list(INVERTER_SENSOR_TYPES) + elif device_coordinator.device_type == "tlx": + sensor_descriptions = list(TLX_SENSOR_TYPES) + elif device_coordinator.device_type == "storage": + sensor_descriptions = list(STORAGE_SENSOR_TYPES) + elif device_coordinator.device_type == "mix": + sensor_descriptions = list(MIX_SENSOR_TYPES) else: _LOGGER.debug( "Device type %s was found but is not supported right now", - device["deviceType"], + device_coordinator.device_type, ) entities.extend( - [ - GrowattInverter( - probe, - name=f"{device['deviceAilas']}", - unique_id=f"{device['deviceSn']}-{description.key}", - description=description, - ) - for description in sensor_descriptions - ] + GrowattSensor( + device_coordinator, + name=device_sn, + serial_id=device_sn, + unique_id=f"{device_sn}-{description.key}", + description=description, + ) + for description in sensor_descriptions ) - async_add_entities(entities, True) + async_add_entities(entities) -class GrowattInverter(SensorEntity): +class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity): """Representation of a Growatt Sensor.""" _attr_has_entity_name = True - entity_description: GrowattSensorEntityDescription def __init__( - self, probe, name, unique_id, description: GrowattSensorEntityDescription + self, + coordinator: GrowattCoordinator, + name: str, + serial_id: str, + unique_id: str, + description: GrowattSensorEntityDescription, ) -> None: """Initialize a PVOutput sensor.""" - self.probe = probe + super().__init__(coordinator) self.entity_description = description self._attr_unique_id = unique_id self._attr_icon = "mdi:solar-power" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, probe.device_id)}, + identifiers={(DOMAIN, serial_id)}, manufacturer="Growatt", name=name, ) @property - def native_value(self): + def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" - result = self.probe.get_data(self.entity_description) - if self.entity_description.precision is not None: + result = self.coordinator.get_data(self.entity_description) + if ( + isinstance(result, (int, float)) + and self.entity_description.precision is not None + ): result = round(result, self.entity_description.precision) return result @@ -171,182 +119,5 @@ class GrowattInverter(SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if self.entity_description.currency: - return self.probe.get_currency() + return self.coordinator.get_currency() return super().native_unit_of_measurement - - def update(self) -> None: - """Get the latest data from the Growat API and updates the state.""" - self.probe.update() - - -class GrowattData: - """The class for handling data retrieval.""" - - def __init__(self, api, username, password, device_id, growatt_type): - """Initialize the probe.""" - - self.growatt_type = growatt_type - self.api = api - self.device_id = device_id - self.plant_id = None - self.data = {} - self.previous_values = {} - self.username = username - self.password = password - - @Throttle(SCAN_INTERVAL) - def update(self): - """Update probe data.""" - self.api.login(self.username, self.password) - _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.growatt_type) - try: - if self.growatt_type == "total": - total_info = self.api.plant_info(self.device_id) - del total_info["deviceList"] - # PlantMoneyText comes in as "3.1/€" split between value and currency - plant_money_text, currency = total_info["plantMoneyText"].split("/") - total_info["plantMoneyText"] = plant_money_text - total_info["currency"] = currency - self.data = total_info - elif self.growatt_type == "inverter": - inverter_info = self.api.inverter_detail(self.device_id) - self.data = inverter_info - elif self.growatt_type == "tlx": - tlx_info = self.api.tlx_detail(self.device_id) - self.data = tlx_info["data"] - elif self.growatt_type == "storage": - storage_info_detail = self.api.storage_params(self.device_id)[ - "storageDetailBean" - ] - storage_energy_overview = self.api.storage_energy_overview( - self.plant_id, self.device_id - ) - self.data = {**storage_info_detail, **storage_energy_overview} - elif self.growatt_type == "mix": - mix_info = self.api.mix_info(self.device_id) - mix_totals = self.api.mix_totals(self.device_id, self.plant_id) - mix_system_status = self.api.mix_system_status( - self.device_id, self.plant_id - ) - - mix_detail = self.api.mix_detail(self.device_id, self.plant_id) - # Get the chart data and work out the time of the last entry, use this - # as the last time data was published to the Growatt Server - mix_chart_entries = mix_detail["chartData"] - sorted_keys = sorted(mix_chart_entries) - - # Create datetime from the latest entry - date_now = dt_util.now().date() - last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) - mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt_util.get_default_time_zone() - ) - - # Dashboard data is largely inaccurate for mix system but it is the only - # call with the ability to return the combined imported from grid value - # that is the combination of charging AND load consumption - dashboard_data = self.api.dashboard_data(self.plant_id) - # Dashboard values have units e.g. "kWh" as part of their returned - # string, so we remove it - dashboard_values_for_mix = { - # etouser is already used by the results from 'mix_detail' so we - # rebrand it as 'etouser_combined' - "etouser_combined": float( - dashboard_data["etouser"].replace("kWh", "") - ) - } - self.data = { - **mix_info, - **mix_totals, - **mix_system_status, - **mix_detail, - **dashboard_values_for_mix, - } - _LOGGER.debug( - "Finished updating data for %s (%s)", - self.device_id, - self.growatt_type, - ) - except json.decoder.JSONDecodeError: - _LOGGER.error("Unable to fetch data from Growatt server") - - def get_currency(self): - """Get the currency.""" - return self.data.get("currency") - - def get_data(self, entity_description): - """Get the data.""" - _LOGGER.debug( - "Data request for: %s", - entity_description.name, - ) - variable = entity_description.api_key - api_value = self.data.get(variable) - previous_value = self.previous_values.get(variable) - return_value = api_value - - # If we have a 'drop threshold' specified, then check it and correct if needed - if ( - entity_description.previous_value_drop_threshold is not None - and previous_value is not None - and api_value is not None - ): - _LOGGER.debug( - ( - "%s - Drop threshold specified (%s), checking for drop... API" - " Value: %s, Previous Value: %s" - ), - entity_description.name, - entity_description.previous_value_drop_threshold, - api_value, - previous_value, - ) - diff = float(api_value) - float(previous_value) - - # Check if the value has dropped (negative value i.e. < 0) and it has only - # dropped by a small amount, if so, use the previous value. - # Note - The energy dashboard takes care of drops within 10% - # of the current value, however if the value is low e.g. 0.2 - # and drops by 0.1 it classes as a reset. - if -(entity_description.previous_value_drop_threshold) <= diff < 0: - _LOGGER.debug( - ( - "Diff is negative, but only by a small amount therefore not a" - " nightly reset, using previous value (%s) instead of api value" - " (%s)" - ), - previous_value, - api_value, - ) - return_value = previous_value - else: - _LOGGER.debug( - "%s - No drop detected, using API value", entity_description.name - ) - - # Lifetime total values should always be increasing, they will never reset, - # however the API sometimes returns 0 values when the clock turns to 00:00 - # local time in that scenario we should just return the previous value - # Scenarios: - # 1 - System has a genuine 0 value when it it first commissioned: - # - will return 0 until a non-zero value is registered - # 2 - System has been running fine but temporarily resets to 0 briefly - # at midnight: - # - will return the previous value - # 3 - HA is restarted during the midnight 'outage' - Not handled: - # - Previous value will not exist meaning 0 will be returned - # - This is an edge case that would be better handled by looking - # up the previous value of the entity from the recorder - if entity_description.never_resets and api_value == 0 and previous_value: - _LOGGER.debug( - ( - "API value is 0, but this value should never reset, returning" - " previous value (%s) instead" - ), - previous_value, - ) - return_value = previous_value - - self.previous_values[variable] = return_value - - return return_value From 7c83fd0bf94a5edf8ac9ca865e3eaab22d183ad9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 23 Jul 2025 15:05:39 +0200 Subject: [PATCH 0536/1118] Add twice_daily forecast to SMHI (#148882) --- homeassistant/components/smhi/coordinator.py | 5 + homeassistant/components/smhi/weather.py | 23 +- .../smhi/snapshots/test_weather.ambr | 292 +++++++++++++++++- tests/components/smhi/test_weather.py | 20 ++ 4 files changed, 333 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index ba7542694df..d8e85917db5 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -24,6 +24,7 @@ class SMHIForecastData: daily: list[SMHIForecast] hourly: list[SMHIForecast] + twice_daily: list[SMHIForecast] class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): @@ -52,6 +53,9 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): async with asyncio.timeout(TIMEOUT): _forecast_daily = await self._smhi_api.async_get_daily_forecast() _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + _forecast_twice_daily = ( + await self._smhi_api.async_get_twice_daily_forecast() + ) except SmhiForecastException as ex: raise UpdateFailed( "Failed to retrieve the forecast from the SMHI API" @@ -60,6 +64,7 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): return SMHIForecastData( daily=_forecast_daily, hourly=_forecast_hourly, + twice_daily=_forecast_twice_daily, ) @property diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ccfff7cc2e5..9496321b8b4 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, @@ -109,7 +110,9 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY ) _attr_name = None @@ -146,7 +149,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): super()._handle_coordinator_update() def _get_forecast_data( - self, forecast_data: list[SMHIForecast] | None + self, forecast_data: list[SMHIForecast] | None, forecast_type: str ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -161,7 +164,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ): condition = ATTR_CONDITION_CLEAR_NIGHT - data.append( + new_forecast = Forecast( { ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], @@ -179,13 +182,23 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) + if forecast_type == "twice_daily": + new_forecast[ATTR_FORECAST_IS_DAYTIME] = False + if forecast["valid_time"].hour == 12: + new_forecast[ATTR_FORECAST_IS_DAYTIME] = True + + data.append(new_forecast) return data def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self.coordinator.data.daily) + return self._get_forecast_data(self.coordinator.data.daily, "daily") def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self.coordinator.data.hourly) + return self._get_forecast_data(self.coordinator.data.hourly, "hourly") + + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Service to retrieve the twice daily forecast.""" + return self._get_forecast_data(self.coordinator.data.twice_daily, "twice_daily") diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 083dcbd6404..2df5bb01a3c 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -68,7 +68,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -287,7 +287,7 @@ 'precipitation_unit': , 'pressure': 992.4, 'pressure_unit': , - 'supported_features': , + 'supported_features': , 'temperature': 18.4, 'temperature_unit': , 'thunder_probability': 37, @@ -299,3 +299,291 @@ 'wind_speed_unit': , }) # --- +# name: test_twice_daily_forecast_service[load_platforms0] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T08:00:00+00:00', + 'humidity': 100, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 992.4, + 'temperature': 18.4, + 'templow': 18.4, + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00+00:00', + 'humidity': 96, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 991.7, + 'temperature': 18.4, + 'templow': 17.1, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-08T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 987.5, + 'temperature': 18.4, + 'templow': 14.8, + 'wind_bearing': 357, + 'wind_gust_speed': 10.44, + 'wind_speed': 3.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'humidity': 97, + 'is_daytime': True, + 'precipitation': 0.3, + 'pressure': 984.1, + 'temperature': 18.4, + 'templow': 12.8, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-09T00:00:00+00:00', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.1, + 'pressure': 995.6, + 'temperature': 18.4, + 'templow': 11.2, + 'wind_bearing': 193, + 'wind_gust_speed': 48.6, + 'wind_speed': 19.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'humidity': 95, + 'is_daytime': True, + 'precipitation': 1.1, + 'pressure': 1001.4, + 'temperature': 18.4, + 'templow': 11.1, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-10T00:00:00+00:00', + 'humidity': 99, + 'is_daytime': False, + 'precipitation': 3.6, + 'pressure': 1007.8, + 'temperature': 18.4, + 'templow': 10.4, + 'wind_bearing': 200, + 'wind_gust_speed': 28.08, + 'wind_speed': 14.4, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00+00:00', + 'humidity': 75, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1011.1, + 'temperature': 18.4, + 'templow': 13.9, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T00:00:00+00:00', + 'humidity': 98, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1012.3, + 'temperature': 18.4, + 'templow': 11.7, + 'wind_bearing': 169, + 'wind_gust_speed': 16.56, + 'wind_speed': 7.56, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00+00:00', + 'humidity': 69, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 18.4, + 'templow': 17.6, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 0, + 'condition': 'clear-night', + 'datetime': '2023-08-12T00:00:00+00:00', + 'humidity': 97, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.8, + 'temperature': 18.4, + 'templow': 12.3, + 'wind_bearing': 191, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00+00:00', + 'humidity': 82, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 18.4, + 'templow': 17.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 12, + 'condition': 'clear-night', + 'datetime': '2023-08-13T00:00:00+00:00', + 'humidity': 92, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1013.9, + 'temperature': 18.4, + 'templow': 13.6, + 'wind_bearing': 233, + 'wind_gust_speed': 20.16, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00+00:00', + 'humidity': 59, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1013.6, + 'temperature': 20.0, + 'templow': 18.4, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T00:00:00+00:00', + 'humidity': 91, + 'is_daytime': False, + 'precipitation': 0.0, + 'pressure': 1015.2, + 'temperature': 18.4, + 'templow': 13.5, + 'wind_bearing': 227, + 'wind_gust_speed': 23.4, + 'wind_speed': 10.8, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00+00:00', + 'humidity': 56, + 'is_daytime': True, + 'precipitation': 0.0, + 'pressure': 1015.3, + 'temperature': 20.8, + 'templow': 18.4, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-15T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 14.3, + 'wind_bearing': 196, + 'wind_gust_speed': 22.32, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00+00:00', + 'humidity': 64, + 'is_daytime': True, + 'precipitation': 2.4, + 'pressure': 1014.3, + 'temperature': 20.4, + 'templow': 18.4, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 38, + 'condition': 'clear-night', + 'datetime': '2023-08-16T00:00:00+00:00', + 'humidity': 93, + 'is_daytime': False, + 'precipitation': 1.2, + 'pressure': 1014.9, + 'temperature': 18.4, + 'templow': 13.8, + 'wind_bearing': 228, + 'wind_gust_speed': 21.24, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00+00:00', + 'humidity': 61, + 'is_daytime': True, + 'precipitation': 1.2, + 'pressure': 1014.0, + 'temperature': 20.2, + 'templow': 18.4, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }), + }) +# --- diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 5cf8c2ae41d..9acacb10ffa 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -473,3 +473,23 @@ async def test_forecast_service( return_response=True, ) assert response == snapshot + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.WEATHER]], +) +async def test_twice_daily_forecast_service( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"entity_id": ENTITY_ID, "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot From 9a9f65dc366a4407dd92dfaeac7c6f18b2218c04 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:06:25 +0200 Subject: [PATCH 0537/1118] Improve config flow tests in Onkyo (#149199) --- .../components/onkyo/quality_scale.yaml | 11 +- tests/components/onkyo/conftest.py | 7 +- tests/components/onkyo/test_config_flow.py | 569 ++++++++---------- 3 files changed, 271 insertions(+), 316 deletions(-) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 1e8bf07e66a..758055a974c 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Coverage is 100%, but the tests need to be improved. + config-flow-test-coverage: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -39,9 +36,9 @@ rules: parallel-updates: todo reauthentication-flow: status: exempt - comment: | - This integration does not require authentication. - test-coverage: todo + comment: This integration does not require authentication. + test-coverage: done + # Gold devices: todo diagnostics: todo diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index c6459a2b1f2..6528168f723 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -8,9 +8,8 @@ from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status import pytest from homeassistant.components.onkyo.const import DOMAIN -from homeassistant.const import CONF_HOST -from . import RECEIVER_INFO, mock_discovery +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery from tests.common import MockConfigEntry @@ -24,7 +23,7 @@ def mock_default_discovery() -> Generator[None]: DEVICE_INTERVIEW_TIMEOUT=1, DEVICE_DISCOVERY_TIMEOUT=1, ), - mock_discovery([RECEIVER_INFO]), + mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]), ): yield @@ -164,7 +163,7 @@ def mock_receiver( @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" - data = {CONF_HOST: RECEIVER_INFO.host} + data = {"host": RECEIVER_INFO.host} options = { "volume_resolution": 80, "max_volume": 100, diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index df10e266982..b56ab4b7028 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,5 +1,7 @@ """Test Onkyo config flow.""" +from contextlib import AbstractContextManager, nullcontext + from aioonkyo import ReceiverInfo import pytest @@ -15,7 +17,7 @@ from homeassistant.components.onkyo.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, SsdpServiceInfo, @@ -26,186 +28,87 @@ from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry -def _entry_title(receiver_info: ReceiverInfo) -> str: +def _receiver_display_name(receiver_info: ReceiverInfo) -> str: return f"{receiver_info.model_name} ({receiver_info.host})" -async def test_user_initial_menu(hass: HomeAssistant) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert init_result["type"] is FlowResultType.MENU - # Check if the values are there, but ignore order - assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} - - -async def test_manual_valid_host(hass: HomeAssistant) -> None: - """Test valid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == _entry_title( - RECEIVER_INFO - ) - - -async def test_manual_invalid_host(hass: HomeAssistant) -> None: - """Test invalid host entered.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - with mock_discovery([]): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "cannot_connect" - - -async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: - """Test valid host entered.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - with mock_discovery(None): - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - assert host_result["step_id"] == "manual" - assert host_result["errors"]["base"] == "unknown" - - -async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None: - """Test eiscp discovery with no devices found.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - with mock_discovery([]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None: - """Test eiscp discovery with an unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - with mock_discovery(None): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - @pytest.mark.usefixtures("mock_setup_entry") -async def test_eiscp_discovery( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, -) -> None: - """Test eiscp discovery.""" - mock_config_entry.add_to_hass(hass) - +async def test_manual(hass: HomeAssistant) -> None: + """Test successful manual.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert result["type"] is FlowResultType.FORM - - assert result["data_schema"] is not None - schema = result["data_schema"].schema - container = schema["device"].container - assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"device": RECEIVER_INFO_2.identifier}, + result["flow_id"], {"next_step_id": "manual"} ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "cannot_connect"), + (mock_discovery([RECEIVER_INFO]), "cannot_connect"), + ], +) @pytest.mark.usefixtures("mock_setup_entry") -async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: - """Test SSDP discovery with valid host.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", +async def test_manual_recoverable_error( + hass: HomeAssistant, mock_discovery: AbstractContextManager, error_reason: str +) -> None: + """Test manual with a recoverable error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] == {"base": error_reason} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) assert result["type"] is FlowResultType.FORM @@ -214,160 +117,234 @@ async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - "volume_resolution": 200, - "input_sources": ["TV"], - "listening_modes": ["THX"], + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["host"] == RECEIVER_INFO.host - assert result["result"].unique_id == RECEIVER_INFO.identifier + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_ssdp_discovery_already_configured( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test SSDP discovery with already configured device.""" - mock_config_entry.add_to_hass(hass) - - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.0.101:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) + """Test manual with an error.""" + await setup_integration(hass, mock_config_entry) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful eiscp discovery.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - with mock_discovery(None): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "eiscp_discovery" + + devices = result["data_schema"].schema["device"].container + assert devices == { + RECEIVER_INFO_2.identifier: _receiver_display_name(RECEIVER_INFO_2) + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": RECEIVER_INFO_2.identifier} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name + + +@pytest.mark.parametrize( + ("mock_discovery", "error_reason"), + [ + (mock_discovery(None), "unknown"), + (mock_discovery([]), "no_devices_found"), + (mock_discovery([RECEIVER_INFO]), "no_devices_found"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test eiscp discovery with an error.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + with mock_discovery: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["reason"] == error_reason -async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: - """Test SSDP discovery with host info error.""" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test successful SSDP discovery.""" + await setup_integration(hass, mock_config_entry) + discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_st="mock_st", - ) - - with mock_discovery([]): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: - """Test SSDP discovery with no location.""" - discovery_info = SsdpServiceInfo( - ssdp_location=None, + ssdp_location=f"http://{RECEIVER_INFO_2.host}:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier + assert result["title"] == RECEIVER_INFO_2.model_name -async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: - """Test SSDP discovery with no host.""" +@pytest.mark.parametrize( + ("ssdp_location", "mock_discovery", "error_reason"), + [ + (None, nullcontext(), "unknown"), + ("http://", nullcontext(), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery(None), "unknown"), + (f"http://{RECEIVER_INFO_2.host}:8080", mock_discovery([]), "cannot_connect"), + ( + f"http://{RECEIVER_INFO_2.host}:8080", + mock_discovery([RECEIVER_INFO]), + "cannot_connect", + ), + (f"http://{RECEIVER_INFO.host}:8080", nullcontext(), "already_configured"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + ssdp_location: str | None, + mock_discovery: AbstractContextManager, + error_reason: str, +) -> None: + """Test SSDP discovery with an error.""" + await setup_integration(hass, mock_config_entry) + discovery_info = SsdpServiceInfo( - ssdp_location="http://", + ssdp_location=ssdp_location, upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_st="mock_st", ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) + with mock_discovery: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery_info + ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" - - -async def test_configure_no_resolution(hass: HomeAssistant) -> None: - """Test receiver configure with no resolution set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"input_sources": ["TV"]}, - ) + assert result["reason"] == error_reason @pytest.mark.usefixtures("mock_setup_entry") async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + DOMAIN, context={"source": SOURCE_USER} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": "manual"}, - ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_HOST: RECEIVER_INFO.host}, + result["flow_id"], {"next_step_id": "manual"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO.host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + assert result["description_placeholders"]["name"] == _receiver_display_name( + RECEIVER_INFO ) result = await hass.config_entries.flow.async_configure( @@ -378,6 +355,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} @@ -389,6 +368,8 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: [], }, ) + + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} @@ -400,6 +381,7 @@ async def test_configure(hass: HomeAssistant) -> None: OPTION_LISTENING_MODES: ["THX"], }, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["options"] == { OPTION_VOLUME_RESOLUTION: 200, @@ -409,36 +391,11 @@ async def test_configure(hass: HomeAssistant) -> None: } -async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: - """Test receiver configure with invalid resolution.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - with pytest.raises(InvalidData): - await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 42, "input_sources": ["TV"]}, - ) - - @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow.""" + """Test successful reconfigure flow.""" await setup_integration(hass, mock_config_entry) old_host = mock_config_entry.data[CONF_HOST] @@ -449,21 +406,19 @@ async def test_reconfigure( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "configure_receiver" - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={OPTION_VOLUME_RESOLUTION: 200}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: mock_config_entry.data[CONF_HOST]} ) - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reconfigure_successful" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={OPTION_VOLUME_RESOLUTION: 200} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data[CONF_HOST] == old_host assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 @@ -474,24 +429,25 @@ async def test_reconfigure( @pytest.mark.usefixtures("mock_setup_entry") -async def test_reconfigure_new_device( +async def test_reconfigure_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test the reconfigure config flow with new device.""" + """Test reconfigure flow with an error.""" await setup_integration(hass, mock_config_entry) old_unique_id = mock_config_entry.unique_id result = await mock_config_entry.start_reconfigure_flow(hass) - with mock_discovery([RECEIVER_INFO_2]): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "unique_id_mismatch" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" # unique id should remain unchanged assert mock_config_entry.unique_id == old_unique_id @@ -519,6 +475,9 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ From 4730c5b8316bb2e7cb1dbfabf9cb70a9d886aa15 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:07:37 +0200 Subject: [PATCH 0538/1118] Add logging to Tuya for devices that cannot be supported (#149192) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/__init__.py | 11 +++++ tests/components/tuya/__init__.py | 4 ++ .../fixtures/ydkt_dolceclima_unsupported.json | 23 +++++++++ .../components/tuya/snapshots/test_init.ambr | 36 ++++++++++++++ tests/components/tuya/test_init.py | 49 +++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json create mode 100644 tests/components/tuya/snapshots/test_init.ambr create mode 100644 tests/components/tuya/test_init.py diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314..6c3aa146158 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,6 +153,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + if not device.status and not device.status_range and not device.function: + # If the device has no status, status_range or function, + # it cannot be supported + LOGGER.info( + "Device %s (%s) has been ignored as it does not provide any" + " standard instructions (status, status_range and function are" + " all empty) - see %s", + device.product_name, + device.id, + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11", + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index d9016d18def..632d05ce931 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,10 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "ydkt_dolceclima_unsupported": [ + # https://github.com/orgs/home-assistant/discussions/288 + # unsupported device - no platforms + ], "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json new file mode 100644 index 00000000000..f50aab00a26 --- /dev/null +++ b/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "DOLCECLIMA 10 HP WIFI", + "category": "ydkt", + "product_id": "jevroj5aguwdbs2e", + "product_name": "DOLCECLIMA 10 HP WIFI", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-09T18:39:25+00:00", + "create_time": "2025-07-09T18:39:25+00:00", + "update_time": "2025-07-09T18:39:25+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr new file mode 100644 index 00000000000..084e9a84401 --- /dev/null +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -0,0 +1,36 @@ +# serializer version: 1 +# name: test_unsupported_device[ydkt_dolceclima_unsupported] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'mock_device_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'DOLCECLIMA 10 HP WIFI (unsupported)', + 'model_id': 'jevroj5aguwdbs2e', + 'name': 'DOLCECLIMA 10 HP WIFI', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py new file mode 100644 index 00000000000..8fbf6fb4e3b --- /dev/null +++ b/tests/components/tuya/test_init.py @@ -0,0 +1,49 @@ +"""Test Tuya initialization.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("mock_device_code", ["ydkt_dolceclima_unsupported"]) +async def test_unsupported_device( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported device.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + # Device is registered + assert ( + dr.async_entries_for_config_entry(device_registry, mock_config_entry.entry_id) + == snapshot + ) + # No entities registered + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + # Information log entry added + assert ( + "Device DOLCECLIMA 10 HP WIFI (mock_device_id) has been ignored" + " as it does not provide any standard instructions (status, status_range" + " and function are all empty) - see " + "https://github.com/tuya/tuya-device-sharing-sdk/issues/11" in caplog.text + ) From 6d3872252b816a08a4581ed8129fb28b8ddc64ce Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 15:09:17 +0200 Subject: [PATCH 0539/1118] Fix one inconsistent spelling of "AppArmor" in `hassio` (#149310) --- homeassistant/components/hassio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index e34aa020c5a..6d67b4b79c0 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -238,7 +238,7 @@ "name": "OS Agent version" }, "apparmor_version": { - "name": "Apparmor version" + "name": "AppArmor version" }, "cpu_percent": { "name": "CPU percent" From 1c8ae8a21b5bd5df441b880a7e73568ccb609ad4 Mon Sep 17 00:00:00 2001 From: Nick Kuiper <65495045+NickKoepr@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:12:53 +0200 Subject: [PATCH 0540/1118] Add switches for blue current integration. (#146210) --- .../components/blue_current/__init__.py | 47 +++-- .../components/blue_current/const.py | 11 ++ .../components/blue_current/icons.json | 11 ++ .../components/blue_current/strings.json | 11 ++ .../components/blue_current/switch.py | 169 ++++++++++++++++++ tests/components/blue_current/__init__.py | 19 ++ tests/components/blue_current/test_sensor.py | 13 +- tests/components/blue_current/test_switch.py | 152 ++++++++++++++++ 8 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/blue_current/switch.py create mode 100644 tests/components/blue_current/test_switch.py diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 775ca16a12a..eeda91a70a3 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -15,23 +15,31 @@ from bluecurrent_api.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE +from .const import ( + CHARGEPOINT_SETTINGS, + CHARGEPOINT_STATUS, + DOMAIN, + EVSE_ID, + LOGGER, + PLUG_AND_CHARGE, + VALUE, +) type BlueCurrentConfigEntry = ConfigEntry[Connector] -PLATFORMS = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] CHARGE_POINTS = "CHARGE_POINTS" DATA = "data" DELAY = 5 GRID = "GRID" OBJECT = "object" -VALUE_TYPES = ["CH_STATUS"] +VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] async def async_setup_entry( @@ -94,7 +102,7 @@ class Connector: elif object_name in VALUE_TYPES: value_data: dict = message[DATA] evse_id = value_data.pop(EVSE_ID) - self.update_charge_point(evse_id, value_data) + self.update_charge_point(evse_id, object_name, value_data) # gets grid key / values elif GRID in object_name: @@ -106,26 +114,37 @@ class Connector: """Handle incoming chargepoint data.""" await asyncio.gather( *( - self.handle_charge_point( - entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] - ) + self.handle_charge_point(entry[EVSE_ID], entry) for entry in charge_points_data ), self.client.get_grid_status(charge_points_data[0][EVSE_ID]), ) - async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + async def handle_charge_point( + self, evse_id: str, charge_point: dict[str, Any] + ) -> None: """Add the chargepoint and request their data.""" - self.add_charge_point(evse_id, model, name) + self.add_charge_point(evse_id, charge_point) await self.client.get_status(evse_id) - def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + def add_charge_point(self, evse_id: str, charge_point: dict[str, Any]) -> None: """Add a charge point to charge_points.""" - self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + self.charge_points[evse_id] = charge_point - def update_charge_point(self, evse_id: str, data: dict) -> None: + def update_charge_point(self, evse_id: str, update_type: str, data: dict) -> None: """Update the charge point data.""" - self.charge_points[evse_id].update(data) + charge_point = self.charge_points[evse_id] + if update_type == CHARGEPOINT_SETTINGS: + # Update the plug and charge object. The library parses this object to a bool instead of an object. + plug_and_charge = charge_point.get(PLUG_AND_CHARGE) + if plug_and_charge is not None: + plug_and_charge[VALUE] = data[PLUG_AND_CHARGE] + + # Remove the plug and charge object from the data list before updating. + del data[PLUG_AND_CHARGE] + + charge_point.update(data) + self.dispatch_charge_point_update_signal(evse_id) def dispatch_charge_point_update_signal(self, evse_id: str) -> None: diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py index 008e6efa872..33e0e8b1176 100644 --- a/homeassistant/components/blue_current/const.py +++ b/homeassistant/components/blue_current/const.py @@ -8,3 +8,14 @@ LOGGER = logging.getLogger(__package__) EVSE_ID = "evse_id" MODEL_TYPE = "model_type" +PLUG_AND_CHARGE = "plug_and_charge" +VALUE = "value" +PERMISSION = "permission" +CHARGEPOINT_STATUS = "CH_STATUS" +CHARGEPOINT_SETTINGS = "CH_SETTINGS" +BLOCK = "block" +UNAVAILABLE = "unavailable" +AVAILABLE = "available" +LINKED_CHARGE_CARDS = "linked_charge_cards_only" +PUBLIC_CHARGING = "public_charging" +ACTIVITY = "activity" diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json index ce936902e91..28d4acbc1d8 100644 --- a/homeassistant/components/blue_current/icons.json +++ b/homeassistant/components/blue_current/icons.json @@ -30,6 +30,17 @@ "stop_charge_session": { "default": "mdi:stop" } + }, + "switch": { + "plug_and_charge": { + "default": "mdi:ev-plug-type2" + }, + "linked_charge_cards": { + "default": "mdi:account-group" + }, + "block": { + "default": "mdi:lock" + } } } } diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 28eb20fa912..0a99af603cc 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -124,6 +124,17 @@ "reset": { "name": "Reset" } + }, + "switch": { + "plug_and_charge": { + "name": "Plug & Charge" + }, + "linked_charge_cards_only": { + "name": "Linked charging cards only" + }, + "block": { + "name": "Block charge point" + } } } } diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py new file mode 100644 index 00000000000..a0848387901 --- /dev/null +++ b/homeassistant/components/blue_current/switch.py @@ -0,0 +1,169 @@ +"""Support for Blue Current switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PLUG_AND_CHARGE, BlueCurrentConfigEntry, Connector +from .const import ( + AVAILABLE, + BLOCK, + LINKED_CHARGE_CARDS, + PUBLIC_CHARGING, + UNAVAILABLE, + VALUE, +) +from .entity import ChargepointEntity + + +@dataclass(kw_only=True, frozen=True) +class BlueCurrentSwitchEntityDescription(SwitchEntityDescription): + """Describes a Blue Current switch entity.""" + + function: Callable[[Connector, str, bool], Any] + + turn_on_off_fn: Callable[[str, Connector], tuple[bool, bool]] + """Update the switch based on the latest data received from the websocket. The first returned boolean is _attr_is_on, the second one has_value.""" + + +def update_on_value_and_activity( + key: str, evse_id: str, connector: Connector, reverse_is_on: bool = False +) -> tuple[bool, bool]: + """Return the updated state of the switch based on received chargepoint data and activity.""" + + data_object = connector.charge_points[evse_id].get(key) + is_on = data_object[VALUE] if data_object is not None else None + activity = connector.charge_points[evse_id].get("activity") + + if is_on is not None and activity == AVAILABLE: + return is_on if not reverse_is_on else not is_on, True + return False, False + + +def update_block_switch(evse_id: str, connector: Connector) -> tuple[bool, bool]: + """Return the updated data for a block switch.""" + activity = connector.charge_points[evse_id].get("activity") + return activity == UNAVAILABLE, activity in [AVAILABLE, UNAVAILABLE] + + +def update_charge_point( + key: str, evse_id: str, connector: Connector, new_switch_value: bool +) -> None: + """Change charge point data when the state of the switch changes.""" + data_objects = connector.charge_points[evse_id].get(key) + if data_objects is not None: + data_objects[VALUE] = new_switch_value + + +async def set_plug_and_charge(connector: Connector, evse_id: str, value: bool) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_plug_and_charge(evse_id, value) + update_charge_point(PLUG_AND_CHARGE, evse_id, connector, value) + + +async def set_linked_charge_cards( + connector: Connector, evse_id: str, value: bool +) -> None: + """Toggle the plug and charge setting for a specific charging point.""" + await connector.client.set_linked_charge_cards_only(evse_id, value) + update_charge_point(PUBLIC_CHARGING, evse_id, connector, not value) + + +SWITCHES = ( + BlueCurrentSwitchEntityDescription( + key=PLUG_AND_CHARGE, + translation_key=PLUG_AND_CHARGE, + function=set_plug_and_charge, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity(PLUG_AND_CHARGE, evse_id, connector) + ), + ), + BlueCurrentSwitchEntityDescription( + key=LINKED_CHARGE_CARDS, + translation_key=LINKED_CHARGE_CARDS, + function=set_linked_charge_cards, + turn_on_off_fn=lambda evse_id, connector: ( + update_on_value_and_activity( + PUBLIC_CHARGING, evse_id, connector, reverse_is_on=True + ) + ), + ), + BlueCurrentSwitchEntityDescription( + key=BLOCK, + translation_key=BLOCK, + function=lambda connector, evse_id, value: connector.client.block( + evse_id, value + ), + turn_on_off_fn=update_block_switch, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BlueCurrentConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Blue Current switches.""" + connector = entry.runtime_data + + async_add_entities( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + for evse_id in connector.charge_points + for switch in SWITCHES + ) + + +class ChargePointSwitch(ChargepointEntity, SwitchEntity): + """Base charge point switch.""" + + has_value = True + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self.evse_id = evse_id + self._attr_available = True + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + await self.entity_description.function(self.connector, self.evse_id, value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_state = self.entity_description.turn_on_off_fn(self.evse_id, self.connector) + self._attr_is_on = new_state[0] + self.has_value = new_state[1] diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py index 97acff39a62..402d644747a 100644 --- a/tests/components/blue_current/__init__.py +++ b/tests/components/blue_current/__init__.py @@ -4,18 +4,28 @@ from __future__ import annotations from asyncio import Event, Future from dataclasses import dataclass +from typing import Any from unittest.mock import MagicMock, patch from bluecurrent_api import Client +from homeassistant.components.blue_current import EVSE_ID, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import PUBLIC_CHARGING from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEFAULT_CHARGE_POINT_OPTIONS = { + PLUG_AND_CHARGE: {"value": False, "permission": "write"}, + PUBLIC_CHARGING: {"value": True, "permission": "write"}, +} + DEFAULT_CHARGE_POINT = { "evse_id": "101", "model_type": "", "name": "", + "activity": "available", + **DEFAULT_CHARGE_POINT_OPTIONS, } @@ -77,11 +87,20 @@ def create_client_mock( """Send the grid status to the callback.""" await client_mock.receiver({"object": "GRID_STATUS", "data": grid}) + async def update_charge_point( + evse_id: str, event_object: str, settings: dict[str, Any] + ) -> None: + """Update the charge point data by sending an event.""" + await client_mock.receiver( + {"object": event_object, "data": {EVSE_ID: evse_id, **settings}} + ) + client_mock.connect.side_effect = connect client_mock.wait_for_charge_points.side_effect = wait_for_charge_points client_mock.get_charge_points.side_effect = get_charge_points client_mock.get_status.side_effect = get_status client_mock.get_grid_status.side_effect = get_grid_status + client_mock.update_charge_point = update_charge_point return client_mock diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index cf20b7334b4..773ffbccd97 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -7,17 +7,10 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import DEFAULT_CHARGE_POINT, init_integration from tests.common import MockConfigEntry -charge_point = { - "evse_id": "101", - "model_type": "", - "name": "", -} - - charge_point_status = { "actual_v1": 14, "actual_v2": 18, @@ -97,7 +90,7 @@ async def test_sensors_created( hass, config_entry, "sensor", - charge_point, + DEFAULT_CHARGE_POINT, charge_point_status | charge_point_status_timestamps, grid, ) @@ -116,7 +109,7 @@ async def test_sensors( ) -> None: """Test the underlying sensors.""" await init_integration( - hass, config_entry, "sensor", charge_point, charge_point_status, grid + hass, config_entry, "sensor", DEFAULT_CHARGE_POINT, charge_point_status, grid ) for entity_id, key in charge_point_entity_ids.items(): diff --git a/tests/components/blue_current/test_switch.py b/tests/components/blue_current/test_switch.py new file mode 100644 index 00000000000..c7837816d75 --- /dev/null +++ b/tests/components/blue_current/test_switch.py @@ -0,0 +1,152 @@ +"""The tests for Bluecurrent switches.""" + +from homeassistant.components.blue_current import CHARGEPOINT_SETTINGS, PLUG_AND_CHARGE +from homeassistant.components.blue_current.const import ( + ACTIVITY, + CHARGEPOINT_STATUS, + PUBLIC_CHARGING, + UNAVAILABLE, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import DEFAULT_CHARGE_POINT, init_integration + +from tests.common import MockConfigEntry + + +async def test_switches( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the underlying switches.""" + + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.unique_id == switch.unique_id + + +async def test_switches_offline( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if switches are disabled when needed.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "offline" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == UNAVAILABLE + entry = entity_registry.async_get(switch.entity_id) + assert entry and entry.entity_id == switch.entity_id + + +async def test_block_switch_availability( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the block switch is unavailable when charging.""" + charge_point = DEFAULT_CHARGE_POINT.copy() + charge_point[ACTIVITY] = "charging" + + await init_integration(hass, config_entry, Platform.SWITCH, charge_point) + + state = hass.states.get("switch.101_block_charge_point") + assert state and state.state == UNAVAILABLE + + +async def test_toggle( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test the on / off methods and if the switch gets updated.""" + await init_integration(hass, config_entry, Platform.SWITCH) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + + assert state and state.state == STATE_OFF + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": switch.entity_id}, + blocking=True, + ) + + state = hass.states.get(switch.entity_id) + assert state and state.state == STATE_OFF + + +async def test_setting_change( + hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: EntityRegistry +) -> None: + """Test if the state of the switches are updated when an update message from the websocket comes in.""" + integration = await init_integration(hass, config_entry, Platform.SWITCH) + client_mock = integration[0] + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for switch in entity_entries: + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await client_mock.update_charge_point( + "101", + CHARGEPOINT_SETTINGS, + { + PLUG_AND_CHARGE: True, + PUBLIC_CHARGING: {"value": False, "permission": "write"}, + }, + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_ON + + plug_and_charge_switch = hass.states.get("switch.101_block_charge_point") + assert plug_and_charge_switch.state == STATE_OFF + + await client_mock.update_charge_point( + "101", CHARGEPOINT_STATUS, {ACTIVITY: UNAVAILABLE} + ) + + charge_cards_only_switch = hass.states.get("switch.101_linked_charging_cards_only") + assert charge_cards_only_switch.state == STATE_UNAVAILABLE + + plug_and_charge_switch = hass.states.get("switch.101_plug_charge") + assert plug_and_charge_switch.state == STATE_UNAVAILABLE + + switch = hass.states.get("switch.101_block_charge_point") + assert switch.state == STATE_ON From d9b25770ad3dc047e17b873d0c1370e4956ca5ee Mon Sep 17 00:00:00 2001 From: Imeon-Energy Date: Wed, 23 Jul 2025 15:32:32 +0200 Subject: [PATCH 0541/1118] Remove sensors from Imeon Inverter (#148542) Co-authored-by: TheBushBoy Co-authored-by: Joost Lekkerkerker --- .../components/imeon_inverter/coordinator.py | 7 +- .../components/imeon_inverter/icons.json | 27 - .../components/imeon_inverter/manifest.json | 2 +- .../components/imeon_inverter/sensor.py | 69 --- .../components/imeon_inverter/strings.json | 27 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../imeon_inverter/snapshots/test_sensor.ambr | 503 ------------------ 8 files changed, 5 insertions(+), 634 deletions(-) diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py index 8342240b9ff..f1963a45579 100644 --- a/homeassistant/components/imeon_inverter/coordinator.py +++ b/homeassistant/components/imeon_inverter/coordinator.py @@ -88,10 +88,7 @@ class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): # Store data for key, val in self._api.storage.items(): - if key == "timeline": - data[key] = val - else: - for sub_key, sub_val in val.items(): - data[f"{key}_{sub_key}"] = sub_val + for sub_key, sub_val in val.items(): + data[f"{key}_{sub_key}"] = sub_val return data diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json index 1c74cf4c745..6ede2416afa 100644 --- a/homeassistant/components/imeon_inverter/icons.json +++ b/homeassistant/components/imeon_inverter/icons.json @@ -1,12 +1,6 @@ { "entity": { "sensor": { - "battery_autonomy": { - "default": "mdi:battery-clock" - }, - "battery_charge_time": { - "default": "mdi:battery-charging" - }, "battery_power": { "default": "mdi:battery" }, @@ -58,9 +52,6 @@ "meter_power": { "default": "mdi:power-plug" }, - "meter_power_protocol": { - "default": "mdi:protocol" - }, "output_current_l1": { "default": "mdi:current-ac" }, @@ -115,30 +106,12 @@ "temp_component_temperature": { "default": "mdi:thermometer" }, - "monitoring_building_consumption": { - "default": "mdi:home-lightning-bolt" - }, - "monitoring_economy_factor": { - "default": "mdi:chart-bar" - }, - "monitoring_grid_consumption": { - "default": "mdi:transmission-tower" - }, - "monitoring_grid_injection": { - "default": "mdi:transmission-tower-export" - }, - "monitoring_grid_power_flow": { - "default": "mdi:power-plug" - }, "monitoring_self_consumption": { "default": "mdi:percent" }, "monitoring_self_sufficiency": { "default": "mdi:percent" }, - "monitoring_solar_production": { - "default": "mdi:solar-power" - }, "monitoring_minute_building_consumption": { "default": "mdi:home-lightning-bolt" }, diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json index 1398521dc45..a9a37f3fd9c 100644 --- a/homeassistant/components/imeon_inverter/manifest.json +++ b/homeassistant/components/imeon_inverter/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["imeon_inverter_api==0.3.12"], + "requirements": ["imeon_inverter_api==0.3.14"], "ssdp": [ { "manufacturer": "IMEON", diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py index e1d05d0ecf6..32d40923fa1 100644 --- a/homeassistant/components/imeon_inverter/sensor.py +++ b/homeassistant/components/imeon_inverter/sensor.py @@ -18,7 +18,6 @@ from homeassistant.const import ( UnitOfFrequency, UnitOfPower, UnitOfTemperature, - UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,20 +33,6 @@ _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS = ( # Battery - SensorEntityDescription( - key="battery_autonomy", - translation_key="battery_autonomy", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="battery_charge_time", - translation_key="battery_charge_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="battery_power", translation_key="battery_power", @@ -171,13 +156,6 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key="meter_power_protocol", - translation_key="meter_power_protocol", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), # AC Output SensorEntityDescription( key="output_current_l1", @@ -308,45 +286,6 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, ), # Monitoring (data over the last 24 hours) - SensorEntityDescription( - key="monitoring_building_consumption", - translation_key="monitoring_building_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_economy_factor", - translation_key="monitoring_economy_factor", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_consumption", - translation_key="monitoring_grid_consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_injection", - translation_key="monitoring_grid_injection", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), - SensorEntityDescription( - key="monitoring_grid_power_flow", - translation_key="monitoring_grid_power_flow", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), SensorEntityDescription( key="monitoring_self_consumption", translation_key="monitoring_self_consumption", @@ -361,14 +300,6 @@ SENSOR_DESCRIPTIONS = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, ), - SensorEntityDescription( - key="monitoring_solar_production", - translation_key="monitoring_solar_production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - suggested_display_precision=2, - ), # Monitoring (instant minute data) SensorEntityDescription( key="monitoring_minute_building_consumption", diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json index 218e1c4e4aa..86855361b8f 100644 --- a/homeassistant/components/imeon_inverter/strings.json +++ b/homeassistant/components/imeon_inverter/strings.json @@ -29,12 +29,6 @@ }, "entity": { "sensor": { - "battery_autonomy": { - "name": "Battery autonomy" - }, - "battery_charge_time": { - "name": "Battery charge time" - }, "battery_power": { "name": "Battery power" }, @@ -86,9 +80,6 @@ "meter_power": { "name": "Meter power" }, - "meter_power_protocol": { - "name": "Meter power protocol" - }, "output_current_l1": { "name": "Output current L1" }, @@ -143,30 +134,12 @@ "temp_component_temperature": { "name": "Component temperature" }, - "monitoring_building_consumption": { - "name": "Monitoring building consumption" - }, - "monitoring_economy_factor": { - "name": "Monitoring economy factor" - }, - "monitoring_grid_consumption": { - "name": "Monitoring grid consumption" - }, - "monitoring_grid_injection": { - "name": "Monitoring grid injection" - }, - "monitoring_grid_power_flow": { - "name": "Monitoring grid power flow" - }, "monitoring_self_consumption": { "name": "Monitoring self-consumption" }, "monitoring_self_sufficiency": { "name": "Monitoring self-sufficiency" }, - "monitoring_solar_production": { - "name": "Monitoring solar production" - }, "monitoring_minute_building_consumption": { "name": "Monitoring building consumption (minute)" }, diff --git a/requirements_all.txt b/requirements_all.txt index 479e5598aaf..4837f6e88b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ igloohome-api==0.1.1 ihcsdk==2.8.5 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eae65039b8f..f928f1e2054 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ ifaddr==0.2.0 igloohome-api==0.1.1 # homeassistant.components.imeon_inverter -imeon_inverter_api==0.3.12 +imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib imgw_pib==1.4.2 diff --git a/tests/components/imeon_inverter/snapshots/test_sensor.ambr b/tests/components/imeon_inverter/snapshots/test_sensor.ambr index 8816889f049..fb59aa9dede 100644 --- a/tests/components/imeon_inverter/snapshots/test_sensor.ambr +++ b/tests/components/imeon_inverter/snapshots/test_sensor.ambr @@ -55,118 +55,6 @@ 'state': '25.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-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.imeon_inverter_battery_autonomy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery autonomy', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_autonomy', - 'unique_id': '111111111111111_battery_autonomy', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_autonomy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery autonomy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_autonomy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4.5', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-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.imeon_inverter_battery_charge_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery charge time', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_charge_time', - 'unique_id': '111111111111111_battery_charge_time', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_battery_charge_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Imeon inverter Battery charge time', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_battery_charge_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '120', - }) -# --- # name: test_sensors[sensor.imeon_inverter_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1172,118 +1060,6 @@ 'state': '2000.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-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.imeon_inverter_meter_power_protocol', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Meter power protocol', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'meter_power_protocol', - 'unique_id': '111111111111111_meter_power_protocol', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_meter_power_protocol-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Imeon inverter Meter power protocol', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_meter_power_protocol', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2018.0', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-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.imeon_inverter_monitoring_building_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring building consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_building_consumption', - 'unique_id': '111111111111111_monitoring_building_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring building consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_building_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3000.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_building_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1340,117 +1116,6 @@ 'state': '50.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-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.imeon_inverter_monitoring_economy_factor', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Monitoring economy factor', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_economy_factor', - 'unique_id': '111111111111111_monitoring_economy_factor', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_economy_factor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Imeon inverter Monitoring economy factor', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_economy_factor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.8', - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-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.imeon_inverter_monitoring_grid_consumption', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid consumption', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_consumption', - 'unique_id': '111111111111111_monitoring_grid_consumption', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid consumption', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_consumption', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '500.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_consumption_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1507,62 +1172,6 @@ 'state': '8.3', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-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.imeon_inverter_monitoring_grid_injection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid injection', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_injection', - 'unique_id': '111111111111111_monitoring_grid_injection', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid injection', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_injection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '700.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_injection_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1619,62 +1228,6 @@ 'state': '11.7', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-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.imeon_inverter_monitoring_grid_power_flow', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring grid power flow', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_grid_power_flow', - 'unique_id': '111111111111111_monitoring_grid_power_flow', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring grid power flow', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_grid_power_flow', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-200.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_grid_power_flow_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1841,62 +1394,6 @@ 'state': '90.0', }) # --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-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.imeon_inverter_monitoring_solar_production', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Monitoring solar production', - 'platform': 'imeon_inverter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'monitoring_solar_production', - 'unique_id': '111111111111111_monitoring_solar_production', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.imeon_inverter_monitoring_solar_production-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Imeon inverter Monitoring solar production', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.imeon_inverter_monitoring_solar_production', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2600.0', - }) -# --- # name: test_sensors[sensor.imeon_inverter_monitoring_solar_production_minute-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 22fa86398443e2108fa9760c548a48b64dd5c7df Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 23 Jul 2025 15:33:52 +0200 Subject: [PATCH 0542/1118] Discover ZWA-2 LED as a configuration entity in Z-Wave (#149298) --- .../components/zwave_js/discovery.py | 29 +++ homeassistant/components/zwave_js/light.py | 5 +- tests/components/zwave_js/conftest.py | 38 +++ .../fixtures/nabu_casa_zwa2_legacy_state.json | 231 ++++++++++++++++++ .../fixtures/nabu_casa_zwa2_state.json | 146 +++++++++++ tests/components/zwave_js/test_discovery.py | 48 ++++ 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json create mode 100644 tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 74ffedbc53f..761c80bb0bb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -772,6 +772,35 @@ DISCOVERY_SCHEMAS = [ }, ), ), + # ZWA-2, discover LED control as configuration, default disabled + ## Production firmware (1.0) -> Color Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="color_onoff", + primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + absent_values=[ + SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), + ## Day-1 firmware update (1.1) -> Binary Switch CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + manufacturer_id={0x0466}, + product_id={0x0001}, + product_type={0x0001}, + hint="onoff", + primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + absent_values=[ + COLOR_SWITCH_CURRENT_VALUE_SCHEMA, + SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + ], + entity_category=EntityCategory.CONFIG, + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 23ec240e5a7..a90515cd040 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -183,7 +183,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._supports_color_temp: self._supported_color_modes.add(ColorMode.COLOR_TEMP) if not self._supported_color_modes: - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY: + self._supported_color_modes.add(ColorMode.ONOFF) + else: + self._supported_color_modes.add(ColorMode.BRIGHTNESS) self._calculate_color_values() # Entity class attributes diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1163da4971c..3c07869d5b7 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -538,6 +538,24 @@ def zcombo_smoke_co_alarm_state_fixture() -> NodeDataType: ) +@pytest.fixture(name="nabu_casa_zwa2_state") +def nabu_casa_zwa2_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2.""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_state.json", DOMAIN), + ) + + +@pytest.fixture(name="nabu_casa_zwa2_legacy_state") +def nabu_casa_zwa2_legacy_state_fixture() -> NodeDataType: + """Load node with fixture data for Nabu Casa ZWA-2 (legacy firmware).""" + return cast( + NodeDataType, + load_json_object_fixture("nabu_casa_zwa2_legacy_state.json", DOMAIN), + ) + + # model fixtures @@ -1358,3 +1376,23 @@ def zcombo_smoke_co_alarm_fixture( node = Node(client, zcombo_smoke_co_alarm_state) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="nabu_casa_zwa2") +def nabu_casa_zwa2_fixture( + client: MagicMock, nabu_casa_zwa2_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2.""" + node = Node(client, nabu_casa_zwa2_state) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="nabu_casa_zwa2_legacy") +def nabu_casa_zwa2_legacy_fixture( + client: MagicMock, nabu_casa_zwa2_legacy_state: NodeDataType +) -> Node: + """Load node for Nabu Casa ZWA-2 (legacy firmware).""" + node = Node(client, nabu_casa_zwa2_legacy_state) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json new file mode 100644 index 00000000000..662f7893493 --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -0,0 +1,231 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/data/db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "Home Assistant Connect ZWA-2", + "description": "Z-Wave Adapter", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "Home Assistant Connect ZWA-2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 227 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 181 + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 255, + "green": 227, + "blue": 181 + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 0, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "ffe3b5" + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json new file mode 100644 index 00000000000..31ca446dafc --- /dev/null +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -0,0 +1,146 @@ +{ + "nodeId": 1, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "manufacturerId": 1126, + "productId": 1, + "productType": 1, + "firmwareVersion": "1.0", + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0466/zwa-2.json", + "isEmbedded": true, + "manufacturer": "Nabu Casa", + "manufacturerId": 1126, + "label": "Home Assistant Connect ZWA-2", + "description": "Z-Wave Adapter", + "devices": [ + { + "productType": 1, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false + }, + "label": "Home Assistant Connect ZWA-2", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0466:0x0001:0x0001:1.0", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "isControllerNode": true, + "keepAwake": false, + "protocol": 0, + "sdkVersion": "7.23.1", + "values": [ + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "commandClass": 112, + "commandClassName": "Configuration", + "property": 0, + "propertyName": "enableTiltIndicator", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enable Tilt Indicator", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false + }, + "value": 1 + } + ], + "endpoints": [ + { + "nodeId": 1, + "index": 0, + "deviceClass": { + "basic": { + "key": 2, + "label": "Static Controller" + }, + "generic": { + "key": 2, + "label": "Static Controller" + }, + "specific": { + "key": 1, + "label": "PC Controller" + } + }, + "commandClasses": [] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 44133db03ac..200c77ce443 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -495,3 +495,51 @@ async def test_aeotec_smart_switch_7( entity_entry = entity_registry.async_get(state.entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG + + +async def test_nabu_casa_zwa2( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery.""" + state = hass.states.get("light.z_wave_adapter") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.ONOFF, + ], "The LED indicator should be an ON/OFF light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) + + +async def test_nabu_casa_zwa2_legacy( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + nabu_casa_zwa2_legacy: Node, + integration: MockConfigEntry, +) -> None: + """Test ZWA-2 discovery with legacy firmware.""" + state = hass.states.get("light.z_wave_adapter") + assert state, "The LED indicator should be enabled by default" + + entry = entity_registry.async_get(state.entity_id) + assert entry, "Entity for the LED indicator not found" + + assert entry.capabilities.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.HS, + ], "The LED indicator should be a color light" + + assert not entry.disabled, "The entity should be enabled by default" + + assert entry.entity_category is EntityCategory.CONFIG, ( + "The LED indicator should be configuration" + ) From 58ddf4ea95ec843e09d76bb69d04bc0ef42bfebd Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:40:09 +0200 Subject: [PATCH 0543/1118] Add note about re-interviewing Z-Wave battery powered devices (#149300) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 4d68aa2bcbc..687d06cd703 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -274,7 +274,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" From fad5f7a47b5d93bb1ff5ad4262727db3ca2a429d Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:52:25 -0400 Subject: [PATCH 0544/1118] Move optimistic platform logic to AbstractTemplateEntity base class (#149245) --- .../components/template/binary_sensor.py | 2 +- homeassistant/components/template/cover.py | 28 +++++++++---------- homeassistant/components/template/entity.py | 19 +++++++++++-- homeassistant/components/template/select.py | 24 +++++----------- .../components/template/template_entity.py | 6 ++++ 5 files changed, 43 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index e8b8efbda0a..567e9e3a110 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -182,7 +182,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._template = config[CONF_STATE] + self._template: template.Template = config[CONF_STATE] self._delay_cancel = None self._delay_on = None self._delay_on_raw = config.get(CONF_DELAY_ON) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0bbc6b77f57..8f88baea091 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -24,7 +24,6 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_NAME, - CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -41,6 +40,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -97,7 +97,6 @@ COVER_YAML_SCHEMA = vol.All( vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, @@ -106,7 +105,9 @@ COVER_YAML_SCHEMA = vol.All( vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -121,7 +122,6 @@ COVER_LEGACY_YAML_SCHEMA = vol.All( vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, @@ -129,7 +129,9 @@ COVER_LEGACY_YAML_SCHEMA = vol.All( vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } - ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema), + ) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -162,21 +164,17 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._position_template = config.get(CONF_POSITION) self._tilt_template = config.get(CONF_TILT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - optimistic = config.get(CONF_OPTIMISTIC) - self._optimistic = optimistic or ( - optimistic is None and not self._template and not self._position_template - ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template self._position: int | None = None @@ -318,7 +316,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 100}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 100 self.async_write_ha_state() @@ -332,7 +330,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": 0}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self._position = 0 self.async_write_ha_state() @@ -349,7 +347,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): run_variables={"position": self._position}, context=self._context, ) - if self._optimistic: + if self._attr_assumed_state: self.async_write_ha_state() async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -493,10 +491,10 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): updater(rendered) write_ha_state = True - if not self._optimistic: + if not self._attr_assumed_state: self.async_set_context(self.coordinator.data["context"]) write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 31c48917a1f..e9a630594d7 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -4,12 +4,12 @@ from abc import abstractmethod from collections.abc import Sequence from typing import Any -from homeassistant.const import CONF_DEVICE_ID +from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import Template, TemplateStateFromEntityId from homeassistant.helpers.typing import ConfigType from .const import CONF_OBJECT_ID @@ -19,13 +19,26 @@ class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" _entity_id_format: str + _optimistic_entity: bool = False + _template: Template | None = None - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self._optimistic_entity: + self._template = config.get(CONF_STATE) + + self._attr_assumed_state = self._template is None or config.get( + CONF_OPTIMISTIC, False + ) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( self._entity_id_format, object_id, hass=self.hass diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 0ad99cd6ae8..8e298c28539 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -15,7 +15,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -34,6 +34,7 @@ from .helpers import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -45,7 +46,6 @@ CONF_OPTIONS = "options" CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" -DEFAULT_OPTIMISTIC = False SELECT_COMMON_SCHEMA = vol.Schema( { @@ -55,15 +55,9 @@ SELECT_COMMON_SCHEMA = vol.Schema( } ) -SELECT_YAML_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } - ) - .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - .extend(SELECT_COMMON_SCHEMA.schema) -) +SELECT_YAML_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema @@ -117,24 +111,20 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = ( - self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC) - ) self._attr_options = [] self._attr_current_option = None async def async_select_option(self, option: str) -> None: """Change the selected option.""" - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() if select_option := self._action_scripts.get(CONF_SELECT_OPTION): diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ae473854502..1bc49bceafd 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_OPTIMISTIC, CONF_PATH, CONF_VARIABLES, STATE_UNKNOWN, @@ -100,6 +101,11 @@ TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) +TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA = { + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, +} + + def make_template_entity_common_modern_schema( default_name: str, ) -> vol.Schema: From 23b293617474edec4df02c2b99b77f848ab3f184 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:53:36 +0200 Subject: [PATCH 0545/1118] Replace RuntimeError with custom ServiceValidationError in Tuya (#149175) --- homeassistant/components/tuya/humidifier.py | 16 +- homeassistant/components/tuya/number.py | 3 +- homeassistant/components/tuya/strings.json | 5 + homeassistant/components/tuya/util.py | 28 +++ tests/components/tuya/test_humidifier.py | 184 ++++++++++++++++++++ tests/components/tuya/test_number.py | 75 ++++++++ 6 files changed, 308 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6539d98e9d8..06fdc1545c5 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,6 +21,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError @dataclass(frozen=True) @@ -169,17 +170,28 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": True}]) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" + if self._switch_dpcode is None: + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.dpcode or self.entity_description.key, + ) self._send_command([{"code": self._switch_dpcode, "value": False}]) def set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._set_humidity is None: - raise RuntimeError( - "Cannot set humidity, device doesn't provide methods to set it" + raise ActionDPCodeNotFoundError( + self.device, + self.entity_description.humidity, ) self._send_command( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 383ece6eaee..e7988adfafb 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -26,6 +26,7 @@ from .const import ( ) from .entity import TuyaEntity from .models import IntegerTypeData +from .util import ActionDPCodeNotFoundError # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. @@ -473,7 +474,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: - raise RuntimeError("Cannot set value, device doesn't provide type data") + raise ActionDPCodeNotFoundError(self.device, self.entity_description.key) self._send_command( [ diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index abcafc490f9..954f5dbda8a 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -916,5 +916,10 @@ "name": "Siren" } } + }, + "exceptions": { + "action_dpcode_not_found": { + "message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})." + } } } diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index c1615b89c2d..916a7cfddf4 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -2,6 +2,12 @@ from __future__ import annotations +from tuya_sharing import CustomerDevice + +from homeassistant.exceptions import ServiceValidationError + +from .const import DOMAIN, DPCode + def remap_value( value: float, @@ -15,3 +21,25 @@ def remap_value( if reverse: value = from_max - value + from_min return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min + + +class ActionDPCodeNotFoundError(ServiceValidationError): + """Custom exception for action DP code not found errors.""" + + def __init__( + self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None + ) -> None: + """Initialize the error with device and expected DP codes.""" + if expected is None: + expected = () # empty tuple for no expected codes + elif isinstance(expected, str): + expected = (DPCode(expected),) + + super().__init__( + translation_domain=DOMAIN, + translation_key="action_dpcode_not_found", + translation_placeholders={ + "expected": str(sorted([dp.value for dp in expected])), + "available": str(sorted(device.function.keys())), + }, + ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index f4cd264a03c..d4996bcd32a 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -8,9 +8,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -54,3 +61,180 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_on( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": True}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_turn_off( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch", "value": False}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier"], +) +async def test_set_humidity( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service.""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_on_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn on service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_turn_off_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test turn off service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['switch', 'switch_spray']", + "available": ("[]"), + } + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_smart_dry_plus"], +) +async def test_set_humidity_unsupported( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set humidity service (not supported by this device).""" + entity_id = "humidifier.dehumidifier" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": entity_id, + "humidity": 50, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['dehumidify_set_value']", + "available": ("[]"), + } diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 7da514964aa..b6c7b1f6de5 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -8,9 +8,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +55,76 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value.""" + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "delay_set", "value": 18}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["mal_alarm_host"], +) +async def test_set_value_no_function( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test set value when no function available.""" + + # Mock a device with delay_set in status but not in function or status_range + mock_device.function.pop("delay_set") + mock_device.status_range.pop("delay_set") + + entity_id = "number.multifunction_alarm_arm_delay" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": entity_id, + "value": 18, + }, + blocking=True, + ) + assert err.value.translation_key == "action_dpcode_not_found" + assert err.value.translation_placeholders == { + "expected": "['delay_set']", + "available": ( + "['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', " + "'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', " + "'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', " + "'switch_kb_sound', 'switch_mode_sound']" + ), + } From b6db10340e2e14ae5969e15492caa7a0319a5765 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 23 Jul 2025 07:54:06 -0700 Subject: [PATCH 0546/1118] Update supported languages for Google Generative AI TTS and STT (#149154) --- .../google_generative_ai_conversation/stt.py | 147 +++++++++--------- .../google_generative_ai_conversation/tts.py | 3 + 2 files changed, 79 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py index bdf8a2fd7bf..f9b91ff6685 100644 --- a/homeassistant/components/google_generative_ai_conversation/stt.py +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -53,103 +53,51 @@ class GoogleGenerativeAISttEntity( """Return a list of supported languages.""" return [ "af-ZA", - "sq-AL", "am-ET", - "ar-DZ", + "ar-AE", "ar-BH", + "ar-DZ", "ar-EG", - "ar-IQ", "ar-IL", + "ar-IQ", "ar-JO", "ar-KW", "ar-LB", "ar-MA", "ar-OM", + "ar-PS", "ar-QA", "ar-SA", - "ar-PS", "ar-TN", - "ar-AE", "ar-YE", - "hy-AM", "az-AZ", - "eu-ES", + "bg-BG", "bn-BD", "bn-IN", "bs-BA", - "bg-BG", - "my-MM", "ca-ES", - "zh-CN", - "zh-TW", - "hr-HR", "cs-CZ", "da-DK", - "nl-BE", - "nl-NL", + "de-AT", + "de-CH", + "de-DE", + "el-GR", "en-AU", "en-CA", + "en-GB", "en-GH", "en-HK", - "en-IN", "en-IE", + "en-IN", "en-KE", - "en-NZ", "en-NG", - "en-PK", + "en-NZ", "en-PH", + "en-PK", "en-SG", - "en-ZA", "en-TZ", - "en-GB", "en-US", - "et-EE", - "fil-PH", - "fi-FI", - "fr-BE", - "fr-CA", - "fr-FR", - "fr-CH", - "gl-ES", - "ka-GE", - "de-AT", - "de-DE", - "de-CH", - "el-GR", - "gu-IN", - "iw-IL", - "hi-IN", - "hu-HU", - "is-IS", - "id-ID", - "it-IT", - "it-CH", - "ja-JP", - "jv-ID", - "kn-IN", - "kk-KZ", - "km-KH", - "ko-KR", - "lo-LA", - "lv-LV", - "lt-LT", - "mk-MK", - "ms-MY", - "ml-IN", - "mr-IN", - "mn-MN", - "ne-NP", - "no-NO", - "fa-IR", - "pl-PL", - "pt-BR", - "pt-PT", - "ro-RO", - "ru-RU", - "sr-RS", - "si-LK", - "sk-SK", - "sl-SI", + "en-ZA", "es-AR", "es-BO", "es-CL", @@ -157,27 +105,81 @@ class GoogleGenerativeAISttEntity( "es-CR", "es-DO", "es-EC", - "es-SV", + "es-ES", "es-GT", "es-HN", "es-MX", "es-NI", "es-PA", - "es-PY", "es-PE", "es-PR", - "es-ES", + "es-PY", + "es-SV", "es-US", "es-UY", "es-VE", + "et-EE", + "eu-ES", + "fa-IR", + "fi-FI", + "fil-PH", + "fr-BE", + "fr-CA", + "fr-CH", + "fr-FR", + "ga-IE", + "gl-ES", + "gu-IN", + "he-IL", + "hi-IN", + "hr-HR", + "hu-HU", + "hy-AM", + "id-ID", + "is-IS", + "it-CH", + "it-IT", + "iw-IL", + "ja-JP", + "jv-ID", + "ka-GE", + "kk-KZ", + "km-KH", + "kn-IN", + "ko-KR", + "lb-LU", + "lo-LA", + "lt-LT", + "lv-LV", + "mk-MK", + "ml-IN", + "mn-MN", + "mr-IN", + "ms-MY", + "my-MM", + "nb-NO", + "ne-NP", + "nl-BE", + "nl-NL", + "no-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "si-LK", + "sk-SK", + "sl-SI", + "sq-AL", + "sr-RS", "su-ID", + "sv-SE", "sw-KE", "sw-TZ", - "sv-SE", "ta-IN", + "ta-LK", "ta-MY", "ta-SG", - "ta-LK", "te-IN", "th-TH", "tr-TR", @@ -186,6 +188,9 @@ class GoogleGenerativeAISttEntity( "ur-PK", "uz-UZ", "vi-VN", + "zh-CN", + "zh-HK", + "zh-TW", "zu-ZA", ] diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bc5b0c6cb6..08e83242fcd 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -48,10 +48,13 @@ class GoogleGenerativeAITextToSpeechEntity( _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + # Note the documentation might not be up to date, e.g. el-GR is not listed + # there but is supported. _attr_supported_languages = [ "ar-EG", "bn-BD", "de-DE", + "el-GR", "en-IN", "en-US", "es-US", From 391b1440338ea8ba703c4c7839e082e8a81fef21 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 23 Jul 2025 16:55:00 +0200 Subject: [PATCH 0547/1118] Update Z-Wave LED entity name for ZWA-2 (#149323) --- .../components/zwave_js/discovery.py | 4 +-- homeassistant/components/zwave_js/light.py | 32 ++++++++++++++++++- .../fixtures/nabu_casa_zwa2_legacy_state.json | 6 ++-- .../fixtures/nabu_casa_zwa2_state.json | 6 ++-- tests/components/zwave_js/test_discovery.py | 20 ++++++++++-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 761c80bb0bb..25c342cf87d 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -779,7 +779,7 @@ DISCOVERY_SCHEMAS = [ manufacturer_id={0x0466}, product_id={0x0001}, product_type={0x0001}, - hint="color_onoff", + hint="zwa2_led_color", primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA, absent_values=[ SWITCH_BINARY_CURRENT_VALUE_SCHEMA, @@ -793,7 +793,7 @@ DISCOVERY_SCHEMAS = [ manufacturer_id={0x0466}, product_id={0x0001}, product_type={0x0001}, - hint="onoff", + hint="zwa2_led_onoff", primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, absent_values=[ COLOR_SWITCH_CURRENT_VALUE_SCHEMA, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a90515cd040..9b7c0222410 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -77,7 +77,11 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - if info.platform_hint == "color_onoff": + if info.platform_hint == "zwa2_led_color": + async_add_entities([ZWA2LEDColorLight(config_entry, driver, info)]) + elif info.platform_hint == "zwa2_led_onoff": + async_add_entities([ZWA2LEDOnOffLight(config_entry, driver, info)]) + elif info.platform_hint == "color_onoff": async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -680,3 +684,29 @@ class ZwaveColorOnOffLight(ZwaveLight): colors, kwargs.get(ATTR_TRANSITION), ) + + +class ZWA2LEDColorLight(ZwaveColorOnOffLight): + """LED entity specific to the ZWA-2 (legacy firmware).""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" + + +class ZWA2LEDOnOffLight(ZwaveLight): + """LED entity specific to the ZWA-2.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the ZWA-2 LED entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = "LED" diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json index 662f7893493..8ea8cdbd009 100644 --- a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_legacy_state.json @@ -14,8 +14,8 @@ "isEmbedded": true, "manufacturer": "Nabu Casa", "manufacturerId": 1126, - "label": "Home Assistant Connect ZWA-2", - "description": "Z-Wave Adapter", + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", "devices": [ { "productType": 1, @@ -28,7 +28,7 @@ }, "preferred": false }, - "label": "Home Assistant Connect ZWA-2", + "label": "NC-ZWA-9734", "interviewAttempts": 0, "isFrequentListening": false, "maxDataRate": 100000, diff --git a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json index 31ca446dafc..e0c57462440 100644 --- a/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json +++ b/tests/components/zwave_js/fixtures/nabu_casa_zwa2_state.json @@ -14,8 +14,8 @@ "isEmbedded": true, "manufacturer": "Nabu Casa", "manufacturerId": 1126, - "label": "Home Assistant Connect ZWA-2", - "description": "Z-Wave Adapter", + "label": "NC-ZWA-9734", + "description": "Home Assistant Connect ZWA-2", "devices": [ { "productType": 1, @@ -28,7 +28,7 @@ }, "preferred": false }, - "label": "Home Assistant Connect ZWA-2", + "label": "NC-ZWA-9734", "interviewAttempts": 0, "isFrequentListening": false, "maxDataRate": 100000, diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 200c77ce443..9109d6a4048 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -504,7 +504,7 @@ async def test_nabu_casa_zwa2( integration: MockConfigEntry, ) -> None: """Test ZWA-2 discovery.""" - state = hass.states.get("light.z_wave_adapter") + state = hass.states.get("light.home_assistant_connect_zwa_2_led") assert state, "The LED indicator should be enabled by default" entry = entity_registry.async_get(state.entity_id) @@ -520,6 +520,14 @@ async def test_nabu_casa_zwa2( "The LED indicator should be configuration" ) + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) + async def test_nabu_casa_zwa2_legacy( hass: HomeAssistant, @@ -528,7 +536,7 @@ async def test_nabu_casa_zwa2_legacy( integration: MockConfigEntry, ) -> None: """Test ZWA-2 discovery with legacy firmware.""" - state = hass.states.get("light.z_wave_adapter") + state = hass.states.get("light.home_assistant_connect_zwa_2_led") assert state, "The LED indicator should be enabled by default" entry = entity_registry.async_get(state.entity_id) @@ -543,3 +551,11 @@ async def test_nabu_casa_zwa2_legacy( assert entry.entity_category is EntityCategory.CONFIG, ( "The LED indicator should be configuration" ) + + # Test that the entity name is properly set to "LED" + assert entry.original_name == "LED", ( + "The LED entity should have the original name 'LED'" + ) + assert state.attributes["friendly_name"] == "Home Assistant Connect ZWA-2 LED", ( + "The LED should have the correct friendly name" + ) From ccd22ce0d52dd624e8a08a7b85c457c53d2371ae Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 24 Jul 2025 00:55:44 +1000 Subject: [PATCH 0548/1118] Fix brightness_step and brightness_step_pct via lifx.set_state (#149217) Signed-off-by: Avi Miller --- homeassistant/components/lifx/light.py | 17 ++++++++++ tests/components/lifx/test_light.py | 44 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 3d30fcd369e..7a1b51ac8ae 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -10,6 +10,9 @@ import aiolifx_effects as aiolifx_effects_module import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, LIGHT_TURN_ON_SCHEMA, @@ -234,6 +237,20 @@ class LIFXLight(LIFXEntity, LightEntity): else: fade = 0 + if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index d66908c1b1a..edb13c259e8 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -30,6 +30,8 @@ from homeassistant.components.lifx.manager import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -1735,6 +1737,48 @@ async def test_transitions_color_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() +async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: + """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_new_firmware() + bulb.power_level = 65535 + bulb.color = [0, 0, 32768, 3500] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + # brightness_step should convert from 8 bit to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + # brightness_step_pct should convert from percentage to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + async def test_lifx_set_state_color(hass: HomeAssistant) -> None: """Test lifx.set_state works with color names and RGB.""" config_entry = MockConfigEntry( From 2abd203580b2c9092c089d5466627b5dd8081dbe Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:58:18 +0200 Subject: [PATCH 0549/1118] Bump eheimdigital quality scale to platinum (#148263) --- .../components/eheimdigital/manifest.json | 2 +- .../components/eheimdigital/quality_scale.yaml | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index dba4b6d563c..d414b559aa1 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["eheimdigital"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["eheimdigital==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index 801e0748310..96fa798f9cf 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -46,22 +46,24 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: No repairs. stale-devices: done # Platinum From f679f33c56b46f694973af2d9d2b0d2a847e3404 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:02:59 +0200 Subject: [PATCH 0550/1118] Fix description of `current` field of `keba.set_current` action (#149326) --- homeassistant/components/keba/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index 49ce01f4332..1616df6237b 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -28,7 +28,7 @@ "fields": { "current": { "name": "Current", - "description": "The maximum current used for the charging process. The value is depending on the DIP-switch settings and the used cable of the charging station." + "description": "The maximum current used for the charging process. The value depends on the DIP switch settings and the cable used by the charging station." } } }, From 61807412c41c2f00eaaabf4ee0b3e5f60fcf6812 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:03:12 +0200 Subject: [PATCH 0551/1118] Fix typo "optimisic" in `mqtt` (#149291) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8cb66270331..ba869a7334b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -422,7 +422,7 @@ "tilt_opened_value": "The value that will be sent to the \"tilt command topic\" when the cover tilt is opened.", "tilt_status_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the payload for the tilt status topic. Within the template the following variables are available: `entity_id`, `position_open`, `position_closed`, `tilt_min` and `tilt_max`. [Learn more.]({url}#tilt_status_template)", "tilt_status_topic": "The MQTT topic subscribed to receive tilt status update values. [Learn more.]({url}#tilt_status_topic)", - "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimisic mode by default. [Learn more.]({url}#tilt_optimistic)" + "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)" } }, "light_brightness_settings": { From 15f7dade5e858cdd3e8c9c88e6a06dc19e36f15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Wed, 23 Jul 2025 17:05:35 +0200 Subject: [PATCH 0552/1118] Fix warning about failure to get action during setup phase (#148923) --- homeassistant/components/wmspro/button.py | 2 +- homeassistant/components/wmspro/cover.py | 4 ++-- homeassistant/components/wmspro/light.py | 4 ++-- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index f1ab0489b86..1b2772a9c80 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -23,7 +23,7 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [ WebControlProIdentifyButton(config_entry.entry_id, dest) for dest in hub.dests.values() - if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.Identify) ] async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index b6f100280ad..e7255d478cb 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -32,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.action( + elif dest.hasAction( WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive ): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 52d092ed9f0..2326734ceaf 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -33,9 +33,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.LightDimming): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightDimming): entities.append(WebControlProDimmer(config_entry.entry_id, dest)) - elif dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch): + elif dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightSwitch): entities.append(WebControlProLight(config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 9185768165a..9dbcf09a7d4 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.0"] + "requirements": ["pywmspro==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4837f6e88b2..884a54a9f92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f928f1e2054..1f6cc235624 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2161,7 +2161,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 From 8b7295cd26fc1de4762f192b61acd009ce62ba40 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:16:28 +0200 Subject: [PATCH 0553/1118] Fix three spelling issues in `lg_thinq` (#149322) --- homeassistant/components/lg_thinq/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 65e36a4523e..402816466ea 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -74,7 +74,7 @@ }, "binary_sensor": { "eco_friendly_mode": { - "name": "Eco friendly" + "name": "Eco-friendly" }, "power_save_enabled": { "name": "Power saving mode" @@ -149,7 +149,7 @@ "cliff_error": "Fall prevention sensor has an error", "clutch_error": "Clutch error", "compressor_error": "Compressor error", - "dispensing_error": "Dispensor error", + "dispensing_error": "Dispenser error", "door_close_error": "Door closed error", "door_lock_error": "Door lock error", "door_open_error": "Door open", @@ -233,7 +233,7 @@ "styling_is_complete": "Styling is completed", "time_to_change_filter": "It is time to replace the filter", "time_to_change_water_filter": "You need to replace water filter", - "time_to_clean": "Need to selfcleaning", + "time_to_clean": "Need for self-cleaning", "time_to_clean_filter": "It is time to clean the filter", "timer_is_complete": "Timer has been completed", "washing_is_complete": "Washing is completed", From 5b94f5a99a68623f463b63222249d77015ffbcf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 23 Jul 2025 17:33:24 +0200 Subject: [PATCH 0554/1118] Add more types in TYPE_MAP for Matter Cover (#149188) --- homeassistant/components/matter/cover.py | 6 ++++++ tests/components/matter/snapshots/test_cover.ambr | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2e2d4390b30..7bef7ea1853 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -31,8 +31,14 @@ OPERATIONAL_STATUS_MASK = 0b11 # map Matter window cover types to HA device class TYPE_MAP = { + clusters.WindowCovering.Enums.Type.kRollerShade: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShade2Motor: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior: CoverDeviceClass.SHADE, + clusters.WindowCovering.Enums.Type.kRollerShadeExterior2Motor: CoverDeviceClass.SHADE, clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING, clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN, + clusters.WindowCovering.Enums.Type.kTiltBlindTiltOnly: CoverDeviceClass.BLIND, + clusters.WindowCovering.Enums.Type.kTiltBlindLiftAndTilt: CoverDeviceClass.BLIND, } diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index c8e2c03739a..c0b38a58456 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -124,7 +124,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -140,7 +140,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 51, - 'device_class': 'awning', + 'device_class': 'shade', 'friendly_name': 'Longan link WNCV DA01', 'supported_features': , }), @@ -175,7 +175,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -191,7 +191,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_tilt_position': 100, - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock PA Tilt Window Covering', 'supported_features': , }), @@ -226,7 +226,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'matter', @@ -241,7 +241,7 @@ # name: test_covers[window_covering_tilt][cover.mock_tilt_window_covering-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'awning', + 'device_class': 'blind', 'friendly_name': 'Mock Tilt Window Covering', 'supported_features': , }), From 45edd12f138f5c4ff111bed9bd0e0fb115b56bef Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 23 Jul 2025 17:51:24 +0200 Subject: [PATCH 0555/1118] Bump `imgw_pib` to version 1.5.0 (#149324) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 7b7c66a953d..79118d10de6 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.4.2"] + "requirements": ["imgw_pib==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 884a54a9f92..15ea7aab52d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.4.2 +imgw_pib==1.5.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f6cc235624..4075ecd3b8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.4.2 +imgw_pib==1.5.0 # homeassistant.components.incomfort incomfort-client==0.6.9 From e337abb12d47c69062c73812a3239a1e2044ede0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 17:57:45 +0200 Subject: [PATCH 0556/1118] Clarify setup description in `google_travel_time` (#149327) --- homeassistant/components/google_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index f46d33fda09..b114c3d9225 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or zone friendly name (case sensitive)", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates or an entity ID that provides this information in its state, an entity ID with latitude and longitude attributes, or a zone's friendly name (case-sensitive)", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", From d735af505e5e296d9dc7cdc22fa790f8071fa6ea Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:04:47 +0200 Subject: [PATCH 0557/1118] Sentence-case "app" in `laundrify` (#149328) --- homeassistant/components/laundrify/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 481900775ae..600e6a9bdf0 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -9,7 +9,7 @@ "config": { "step": { "init": { - "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", + "description": "Please enter your personal Auth Code that is shown in the laundrify app.", "data": { "code": "Auth Code (xxx-xxx)" } From 3ed297676f742756f7606f6e1687d9f9121ffbaf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:08:01 +0200 Subject: [PATCH 0558/1118] Remove third "s" from "Home Assistant" in `lametric` (#149329) --- homeassistant/components/lametric/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index dbf25f6680b..f3fa1e81112 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "choice_enter_manual_or_fetch_cloud": { - "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Asssistant can import them from your LaMetric.com account.", + "description": "A LaMetric device can be set up in Home Assistant in two different ways.\n\nYou can enter all device information and API tokens yourself, or Home Assistant can import them from your LaMetric.com account.", "menu_options": { "pick_implementation": "Import from LaMetric.com (recommended)", "manual_entry": "Enter manually" From 5aa629edd09052cca52df06cdaa880ddc12db1da Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:16:00 +0200 Subject: [PATCH 0559/1118] Fix typo in "re-authentication" in `devolo_home_network` (#149312) --- homeassistant/components/devolo_home_network/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 24bf06ac59c..c8c2db34e4c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -105,7 +105,7 @@ "message": "Device {title} did not respond" }, "password_protected": { - "message": "Device {title} requires re-authenticatication to set or change the password" + "message": "Device {title} requires re-authentication to set or change the password" }, "password_wrong": { "message": "The used password is wrong" From d3771571cdc7985e03efeaed855b24d260ada2a0 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jul 2025 18:18:41 +0200 Subject: [PATCH 0560/1118] Bump knx-frontend (#149287) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 5145d2d22f8..6a4565dde0e 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.6.13.181749" + "knx-frontend==2025.7.23.50952" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 15ea7aab52d..f321f6e01e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1304,7 +1304,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.6.13.181749 +knx-frontend==2025.7.23.50952 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4075ecd3b8d..652d041c43b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1126,7 +1126,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.6.13.181749 +knx-frontend==2025.7.23.50952 # homeassistant.components.konnected konnected==1.2.0 From 1312e04c5793bdf0371cb52d2f064c858e5b28c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 18:53:19 +0200 Subject: [PATCH 0561/1118] Fix typos in `update_failed` message of `fritz` (#149330) --- homeassistant/components/fritz/strings.json | 2 +- tests/components/fritz/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index ee23a8cfbef..45d66e9621b 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -214,7 +214,7 @@ "message": "Unable to establish a connection" }, "update_failed": { - "message": "Error while uptaing the data: {error}" + "message": "Error while updating the data: {error}" } } } diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 1b10ddb8fc1..4b352ccb8da 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -57,7 +57,7 @@ async def test_sensor_update_fail( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) await hass.async_block_till_done(wait_background_tasks=True) - assert "Error while uptaing the data: Boom" in caplog.text + assert "Error while updating the data: Boom" in caplog.text sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: From bfa7ff3ede3a0eea7445f2c01904e7bf598aaa6c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 19:07:40 +0200 Subject: [PATCH 0562/1118] Make spelling of "Telldus Live" consistent (#149332) --- homeassistant/components/tellduslive/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index b0750a7785d..17aac10063c 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,8 +11,8 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", - "title": "Authenticate with TelldusLive" + "description": "To link your Telldus Live account:\n1. Open the link below\n2. Log in to Telldus Live\n3. Authorize **{app_name}** (select **Yes**).\n4. Come back here and select **Submit**.\n\n[Link Telldus Live account]({auth_url})", + "title": "Authenticate with Telldus Live" }, "user": { "data": { From b5190788aca909886b210148f0092a9d6883662c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 19:29:31 +0200 Subject: [PATCH 0563/1118] Fix missing sentence-casing of "MAC address" in `anthemav` (#149333) --- homeassistant/components/anthemav/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json index 15e365b3e63..774785f9d29 100644 --- a/homeassistant/components/anthemav/strings.json +++ b/homeassistant/components/anthemav/strings.json @@ -10,7 +10,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on" + "cannot_receive_deviceinfo": "Failed to retrieve MAC address. Make sure the device is turned on" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" From da8ce52ed7e362b0b6c4546aae793a2a978310e5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 23 Jul 2025 20:00:55 +0200 Subject: [PATCH 0564/1118] Fix grammar issues in re-interview description of `zwave_js` (#149337) --- homeassistant/components/zwave_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 687d06cd703..0288fbd7131 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -274,7 +274,7 @@ }, "step": { "init": { - "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device specific and is normally explained in the device manual.", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and the device must be re-interviewed to pick up the changes.\n\nThis is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you decide to proceed with the re-interview, it will take place in the background.\n\nNote: Battery-powered sleeping devices need to be woken up during re-interview for it to work. How to wake up the device is device-specific and is normally explained in the device manual.", "menu_options": { "confirm": "Re-interview device", "ignore": "Ignore device config update" From 40cf47ae5a929f5214cd3879f91c748856bac443 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 23 Jul 2025 20:48:04 +0200 Subject: [PATCH 0565/1118] Bump aioimmich to 0.11.1 (#149335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 16ae1671e3a..6fa8210b878 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "silver", - "requirements": ["aioimmich==0.11.0"] + "requirements": ["aioimmich==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f321f6e01e0..ab474d4a9ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -283,7 +283,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.11.0 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 652d041c43b..87a8212d214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,7 +268,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.11.0 +aioimmich==0.11.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From b966b59c099e2e54f7c9522eb5816af1e20cd52d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 24 Jul 2025 01:37:34 +0200 Subject: [PATCH 0566/1118] Unifiprotect public api snapshot (#149213) --- homeassistant/components/unifiprotect/camera.py | 2 +- tests/components/unifiprotect/test_camera.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 3947324fd73..aa05ec70dd0 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -247,7 +247,7 @@ class ProtectCamera(ProtectDeviceEntity, Camera): if self.channel.is_package: last_image = await self.device.get_package_snapshot(width, height) else: - last_image = await self.device.get_snapshot(width, height) + last_image = await self.device.get_public_api_snapshot() self._last_image = last_image return self._last_image diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 34a1d064547..9c78e09d264 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -396,10 +396,10 @@ async def test_camera_image( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - ufp.api.get_camera_snapshot = AsyncMock() + ufp.api.get_public_api_camera_snapshot = AsyncMock() await async_get_image(hass, "camera.test_camera_high_resolution_channel") - ufp.api.get_camera_snapshot.assert_called_once() + ufp.api.get_public_api_camera_snapshot.assert_called_once() async def test_package_camera_image( From 3f77c13aad0062fc123fb96108e107816e4a6d12 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 02:46:20 +0200 Subject: [PATCH 0567/1118] Fix spelling of "re-authenticate" in `devolo_home_control` (#149342) --- homeassistant/components/devolo_home_control/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 4ec1a35ece2..057faa446e6 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -61,7 +61,7 @@ "message": "Failed to connect to devolo Home Control central unit {gateway_id}." }, "invalid_auth": { - "message": "Authentication failed. Please re-authenticaticate with your mydevolo account." + "message": "Authentication failed. Please re-authenticate with your mydevolo account." }, "maintenance": { "message": "devolo Home Control is currently in maintenance mode." From 7613880645b9afa0348d4b4baaa63df12b163fd8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 02:50:39 +0200 Subject: [PATCH 0568/1118] Fix spelling of "the setup" in `nest` (#149345) --- homeassistant/components/nest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1fc3de9be6b..636a3a0d294 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -55,7 +55,7 @@ "description": "The Nest integration needs to re-authenticate your account" }, "oauth_discovery": { - "description": "Home Assistant has found a Google Nest device on your network. Be aware that the set up of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." + "description": "Home Assistant has found a Google Nest device on your network. Be aware that the setup of Google Nest is more complicated than most other integrations and requires setting up a Nest Device Access project which **requires paying Google a US $5 fee**. Press **Submit** to continue setting up Google Nest." } }, "error": { From 202d8ac802c8f985f6da31622721dd73a4ccf7fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Jul 2025 20:18:59 -1000 Subject: [PATCH 0569/1118] Bump yalexs-ble to 3.1.0 (#149352) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yalexs_ble/test_config_flow.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 9dc66084a45..2368c848eea 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index fee5b0b8310..5b45628ee64 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index b3021bd908e..7a02afbc5d7 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==3.0.0"] + "requirements": ["yalexs-ble==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab474d4a9ce..7f9bd458716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3157,7 +3157,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.0.0 +yalexs-ble==3.1.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87a8212d214..9d2d7390c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2607,7 +2607,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.0.0 +yalexs-ble==3.1.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 1b0df05db2c..c272036097d 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -37,7 +37,7 @@ def _get_mock_push_lock(): mock_push_lock.wait_for_first_update = AsyncMock() mock_push_lock.stop = AsyncMock() mock_push_lock.lock_state = LockState( - LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None + LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None, None, None ) mock_push_lock.lock_status = LockStatus.UNLOCKED mock_push_lock.door_status = DoorStatus.CLOSED From 5543587527326235beb4fe880ee51df7635cbe33 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:22:25 +0200 Subject: [PATCH 0570/1118] Fix spelling of "sea level" in `luftdaten` (#149347) --- homeassistant/components/luftdaten/strings.json | 2 +- tests/components/luftdaten/test_sensor.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index ea842f18ebd..072252cdf21 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -19,7 +19,7 @@ }, "entity": { "sensor": { - "pressure_at_sealevel": { "name": "Pressure at sealevel" } + "pressure_at_sealevel": { "name": "Pressure at sea level" } } } } diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py index f2cf12b3fda..bbabc486355 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -72,16 +72,16 @@ async def test_luftdaten_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.PA assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sealevel") + entry = entity_registry.async_get("sensor.sensor_12345_pressure_at_sea_level") assert entry assert entry.device_id assert entry.unique_id == "12345_pressure_at_sealevel" - state = hass.states.get("sensor.sensor_12345_pressure_at_sealevel") + state = hass.states.get("sensor.sensor_12345_pressure_at_sea_level") assert state assert state.state == "103102.13" assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sealevel" + state.attributes.get(ATTR_FRIENDLY_NAME) == "Sensor 12345 Pressure at sea level" ) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT From c2b1932045e9016490acb2d45e9a65cb0daa33d8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 24 Jul 2025 08:23:02 +0200 Subject: [PATCH 0571/1118] Bump aioonkyo to 0.3.0 (#149336) --- homeassistant/components/onkyo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 07834d4cba1..e465c99052f 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioonkyo"], - "requirements": ["aioonkyo==0.2.0"], + "requirements": ["aioonkyo==0.3.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/requirements_all.txt b/requirements_all.txt index 7f9bd458716..e24f1bcd209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -331,7 +331,7 @@ aiontfy==0.5.3 aionut==4.3.4 # homeassistant.components.onkyo -aioonkyo==0.2.0 +aioonkyo==0.3.0 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d2d7390c39..dffabce4fbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -313,7 +313,7 @@ aiontfy==0.5.3 aionut==4.3.4 # homeassistant.components.onkyo -aioonkyo==0.2.0 +aioonkyo==0.3.0 # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 From 55f01e34859a97501dd4a8a7d797a2f58b187eff Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:23:42 +0200 Subject: [PATCH 0572/1118] Make descriptions of `modbus.stop`/`restart` actions consistent (#149341) --- 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 7d1578558b0..0749ba4a2c8 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -50,7 +50,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Stops modbus hub.", + "description": "Stops a Modbus hub.", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", @@ -60,7 +60,7 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts modbus hub (if running stop then start).", + "description": "Restarts a Modbus hub (if running, stops then starts).", "fields": { "hub": { "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", From 049a6988159aa635c92f88cc2316415abad39e5b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 08:56:49 +0200 Subject: [PATCH 0573/1118] Add missing hyphen to "right-hand drive" in `teslemetry` (#149355) --- homeassistant/components/teslemetry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 57b6053bb48..646a3898cc7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -192,7 +192,7 @@ "name": "European vehicle" }, "right_hand_drive": { - "name": "Right hand drive" + "name": "Right-hand drive" }, "located_at_home": { "name": "Located at home" From fcd514a06b1ed193327a69e01d25e2d1309b5078 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 09:23:21 +0200 Subject: [PATCH 0574/1118] Sentence-case "Still image URL" in `mjpeg` (#149356) --- homeassistant/components/mjpeg/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index 0e1e71fd82c..ed53f6bcdc9 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -6,7 +6,7 @@ "mjpeg_url": "MJPEG URL", "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "still_image_url": "Still Image URL", + "still_image_url": "Still image URL", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } From 53d77c4c1065c2e6cc6a77498a7afd444527ff8f Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 24 Jul 2025 01:08:58 -0700 Subject: [PATCH 0575/1118] Fix Chinese in Google Cloud STT (#149155) --- homeassistant/components/google_cloud/const.py | 10 ++++++++++ homeassistant/components/google_cloud/stt.py | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 16b1463f0f3..3a0b2bc4832 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -186,3 +186,13 @@ STT_LANGUAGES = [ "yue-Hant-HK", "zu-ZA", ] + +# This allows us to support HA's standard codes (e.g., zh-CN) while +# sending the correct code to the Google API (e.g., cmn-Hans-CN). +HA_TO_GOOGLE_STT_LANG_MAP = { + "zh-CN": "cmn-Hans-CN", # Chinese (Mandarin, Simplified, China) + "zh-HK": "yue-Hant-HK", # Chinese (Cantonese, Traditional, Hong Kong) + "zh-TW": "cmn-Hant-TW", # Chinese (Mandarin, Traditional, Taiwan) + "he-IL": "iw-IL", # Hebrew (Google uses 'iw' legacy code) + "nb-NO": "no-NO", # Norwegian Bokmål +} diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 8a548cde8bb..ea438b01cdd 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -8,6 +8,7 @@ import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 +from propcache.api import cached_property from homeassistant.components.stt import ( AudioBitRates, @@ -30,6 +31,7 @@ from .const import ( CONF_STT_MODEL, DEFAULT_STT_MODEL, DOMAIN, + HA_TO_GOOGLE_STT_LANG_MAP, STT_LANGUAGES, ) @@ -68,10 +70,14 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self._client = client self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL) - @property + @cached_property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return STT_LANGUAGES + # Combine the native Google languages and the standard HA languages. + # A set is used to automatically handle duplicates. + supported = set(STT_LANGUAGES) + supported.update(HA_TO_GOOGLE_STT_LANG_MAP.keys()) + return sorted(supported) @property def supported_formats(self) -> list[AudioFormats]: @@ -102,6 +108,10 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" + language_code = HA_TO_GOOGLE_STT_LANG_MAP.get( + metadata.language, metadata.language + ) + streaming_config = speech_v1.StreamingRecognitionConfig( config=speech_v1.RecognitionConfig( encoding=( @@ -110,7 +120,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16 ), sample_rate_hertz=metadata.sample_rate, - language_code=metadata.language, + language_code=language_code, model=self._model, ) ) From 46a01c2060df117ee98b927fe8c399984073d4b2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:23:17 +0200 Subject: [PATCH 0576/1118] Fix config entry name and description in `rainbird.set_rain_delay` action (#149358) --- homeassistant/components/rainbird/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6f92b1bdb97..ca7dc18b8d8 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -80,8 +80,8 @@ "description": "Sets how long automatic irrigation is turned off.", "fields": { "config_entry_id": { - "name": "Rainbird Controller Configuration Entry", - "description": "The setting will be adjusted on the specified controller." + "name": "Rain Bird controller", + "description": "The configuration entry of the controller to adjust the setting." }, "duration": { "name": "Duration", From 2e12d67f2fe47ed66e53ea5786fd7c711d1afed3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:23:30 +0200 Subject: [PATCH 0577/1118] Improve `id_missing` abort message in `samsungtv` (#149357) --- homeassistant/components/samsungtv/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 6251e65b2f8..aa0e77e0b76 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -50,7 +50,7 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Check your TV's External Device Manager settings to authorize Home Assistant.", - "id_missing": "This Samsung device doesn't have a SerialNumber.", + "id_missing": "This Samsung device doesn't have a serial number to identify it.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "not_supported": "This Samsung device is currently not supported.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" From d85ffee27a5cd85e3131d3b0a01a6ad0ce8beaad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:29:34 +0200 Subject: [PATCH 0578/1118] Bump github/codeql-action from 3.29.3 to 3.29.4 (#149354) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cbc343b9d98..cc6014b38b0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.3 + uses: github/codeql-action/init@v3.29.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.3 + uses: github/codeql-action/analyze@v3.29.4 with: category: "/language:python" From f458ede468feadd88a8bab08acdf3361eed42c8b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 10:53:33 +0200 Subject: [PATCH 0579/1118] Small fixes to user-facing strings of `webostv` (#149359) --- homeassistant/components/webostv/strings.json | 12 ++++++------ tests/components/webostv/test_trigger.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index f6d033af632..2f0a413754e 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -12,7 +12,7 @@ } }, "pairing": { - "title": "LG webOS TV Pairing", + "title": "LG webOS TV pairing", "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { @@ -37,7 +37,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "wrong_device": "The configured device is not the same found on this Hostname or IP address." + "wrong_device": "The configured device is not the same found at this hostname or IP address." } }, "options": { @@ -70,7 +70,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities where to run the API method." + "description": "Name(s) of the webOS TV entities where to run the API method." }, "button": { "name": "Button", @@ -92,7 +92,7 @@ }, "payload": { "name": "Payload", - "description": "An optional payload to provide to the endpoint in the format of key value pair(s)." + "description": "An optional payload to provide to the endpoint in the format of key value pairs." } } }, @@ -102,7 +102,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Name(s) of the webostv entities to change sound output on." + "description": "Name(s) of the webOS TV entities to change sound output on." }, "sound_output": { "name": "Sound output", @@ -134,7 +134,7 @@ "message": "Unknown trigger platform: {platform}" }, "invalid_entity_id": { - "message": "Entity {entity_id} is not a valid webostv entity." + "message": "Entity {entity_id} is not a valid webOS TV entity." }, "source_not_found": { "message": "Source {source} not found in the sources list for {name}." diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index c7decafff73..646b8f8034a 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -182,4 +182,4 @@ async def test_trigger_invalid_entity_id( }, ) - assert f"Entity {invalid_entity} is not a valid {DOMAIN} entity" in caplog.text + assert f"Entity {invalid_entity} is not a valid webOS TV entity" in caplog.text From 15f2ae300298299a2e01b10d2942e55ad7ffa454 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:03:02 +0200 Subject: [PATCH 0580/1118] Mark Onkyo quality scale as bronze (#149362) --- homeassistant/components/onkyo/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index e465c99052f..6102f8f2495 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioonkyo"], + "quality_scale": "bronze", "requirements": ["aioonkyo==0.3.0"], "ssdp": [ { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3008c6303ff..04812e9aefa 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1777,7 +1777,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ombi", "omnilogic", "oncue", - "onkyo", "ondilo_ico", "onewire", "onvif", From f5718e1df68c4d1eb8a92a4efa0f5bacb1014d24 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 11:15:57 +0200 Subject: [PATCH 0581/1118] Fix spelling of "autoplay" in `music_assistant` (#149364) --- homeassistant/components/music_assistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index c41bfa70d4c..37f0a8e9a85 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -102,7 +102,7 @@ "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." }, "auto_play": { - "name": "Auto play", + "name": "Autoplay", "description": "Start playing the queue on the target player. Omit to use the default behavior." } } From 393087cf507f416cff677c40159b655933148f77 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 11:50:26 +0200 Subject: [PATCH 0582/1118] Bump `aioshelly` to 13.8.0 (#149365) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 08c9163bb3b..78fc8261bfe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.2"], + "requirements": ["aioshelly==13.8.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e24f1bcd209..cd87da623ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dffabce4fbf..05469693143 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.2 +aioshelly==13.8.0 # homeassistant.components.skybell aioskybell==22.7.0 From eea22d8079df800581a137512ad0e3b45e86be33 Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Thu, 24 Jul 2025 06:29:07 -0400 Subject: [PATCH 0583/1118] Add config flow for datadog (#148104) Co-authored-by: G Johansson --- homeassistant/components/datadog/__init__.py | 100 +++++--- .../components/datadog/config_flow.py | 185 ++++++++++++++ homeassistant/components/datadog/const.py | 10 + .../components/datadog/manifest.json | 1 + homeassistant/components/datadog/strings.json | 56 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/datadog/common.py | 35 +++ tests/components/datadog/test_config_flow.py | 229 ++++++++++++++++++ tests/components/datadog/test_init.py | 115 +++++++-- 10 files changed, 673 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/datadog/config_flow.py create mode 100644 homeassistant/components/datadog/const.py create mode 100644 homeassistant/components/datadog/strings.json create mode 100644 tests/components/datadog/common.py create mode 100644 tests/components/datadog/test_config_flow.py diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index fa852399b09..606f34c9ae0 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -2,9 +2,10 @@ import logging -from datadog import initialize, statsd +from datadog import DogStatsd, initialize import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -17,14 +18,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType +from . import config_flow as config_flow +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) -CONF_RATE = "rate" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8125 -DEFAULT_PREFIX = "hass" -DEFAULT_RATE = 1 -DOMAIN = "datadog" +type DatadogConfigEntry = ConfigEntry[DogStatsd] CONFIG_SCHEMA = vol.Schema( { @@ -43,63 +49,85 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Datadog component.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Datadog integration from YAML, initiating config flow import.""" + if DOMAIN not in config: + return True - conf = config[DOMAIN] - host = conf[CONF_HOST] - port = conf[CONF_PORT] - sample_rate = conf[CONF_RATE] - prefix = conf[CONF_PREFIX] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Set up Datadog from a config entry.""" + + data = entry.data + options = entry.options + host = data[CONF_HOST] + port = data[CONF_PORT] + prefix = options[CONF_PREFIX] + sample_rate = options[CONF_RATE] + + statsd_client = DogStatsd(host=host, port=port, namespace=prefix) + entry.runtime_data = statsd_client initialize(statsd_host=host, statsd_port=port) def logbook_entry_listener(event): - """Listen for logbook entries and send them as events.""" name = event.data.get("name") message = event.data.get("message") - statsd.event( + entry.runtime_data.event( title="Home Assistant", - text=f"%%% \n **{name}** {message} \n %%%", + message=f"%%% \n **{name}** {message} \n %%%", tags=[ f"entity:{event.data.get('entity_id')}", f"domain:{event.data.get('domain')}", ], ) - _LOGGER.debug("Sent event %s", event.data.get("entity_id")) - def state_changed_listener(event): - """Listen for new messages on the bus and sends them to Datadog.""" state = event.data.get("new_state") - if state is None or state.state == STATE_UNKNOWN: return - states = dict(state.attributes) metric = f"{prefix}.{state.domain}" tags = [f"entity:{state.entity_id}"] - for key, value in states.items(): - if isinstance(value, (float, int)): - attribute = f"{metric}.{key.replace(' ', '_')}" + for key, value in state.attributes.items(): + if isinstance(value, (float, int, bool)): value = int(value) if isinstance(value, bool) else value - statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) + attribute = f"{metric}.{key.replace(' ', '_')}" + entry.runtime_data.gauge( + attribute, value, sample_rate=sample_rate, tags=tags + ) try: value = state_helper.state_as_number(state) + entry.runtime_data.gauge(metric, value, sample_rate=sample_rate, tags=tags) except ValueError: - _LOGGER.debug("Error sending %s: %s (tags: %s)", metric, state.state, tags) - return + pass - statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) - - _LOGGER.debug("Sent metric %s: %s (tags: %s)", metric, value, tags) - - hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) - hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener) + entry.async_on_unload( + hass.bus.async_listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener) + ) + entry.async_on_unload( + hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed_listener) + ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool: + """Unload a Datadog config entry.""" + runtime = entry.runtime_data + runtime.flush() + runtime.close_socket() + return True diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py new file mode 100644 index 00000000000..b4486b0967c --- /dev/null +++ b/homeassistant/components/datadog/config_flow.py @@ -0,0 +1,185 @@ +"""Config flow for Datadog.""" + +from typing import Any + +from datadog import DogStatsd +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_RATE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PREFIX, + DEFAULT_RATE, + DOMAIN, +) + + +class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Datadog.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user config flow.""" + errors: dict[str, str] = {} + if user_input: + # Validate connection to Datadog Agent + success = await validate_datadog_connection( + self.hass, + user_input, + ) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + if not success: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=f"Datadog {user_input['host']}", + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + options={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_PREFIX, default=DEFAULT_PREFIX): str, + vol.Required(CONF_RATE, default=DEFAULT_RATE): int, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + # Check for duplicates + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + + result = await self.async_step_user(user_input) + + if errors := result.get("errors"): + await deprecate_yaml_issue(self.hass, False) + return self.async_abort(reason=errors["base"]) + + await deprecate_yaml_issue(self.hass, True) + return result + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow handler.""" + return DatadogOptionsFlowHandler() + + +class DatadogOptionsFlowHandler(OptionsFlow): + """Handle Datadog options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the Datadog options.""" + errors: dict[str, str] = {} + data = self.config_entry.data + options = self.config_entry.options + + if user_input is None: + user_input = {} + + success = await validate_datadog_connection( + self.hass, + {**data, **user_input}, + ) + if success: + return self.async_create_entry( + data={ + CONF_PREFIX: user_input[CONF_PREFIX], + CONF_RATE: user_input[CONF_RATE], + } + ) + + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_PREFIX, default=options[CONF_PREFIX]): str, + vol.Required(CONF_RATE, default=options[CONF_RATE]): int, + } + ), + errors=errors, + ) + + +async def validate_datadog_connection( + hass: HomeAssistant, user_input: dict[str, Any] +) -> bool: + """Attempt to send a test metric to the Datadog agent.""" + try: + client = DogStatsd(user_input[CONF_HOST], user_input[CONF_PORT]) + await hass.async_add_executor_job(client.increment, "connection_test") + except (OSError, ValueError): + return False + else: + return True + + +async def deprecate_yaml_issue( + hass: HomeAssistant, + import_success: bool, +) -> None: + """Create an issue to deprecate YAML config.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.2.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_connection_error", + breaks_in_ha_version="2026.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_connection_error", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Datadog", + "url": f"/config/integrations/dashboard/add?domain={DOMAIN}", + }, + ) diff --git a/homeassistant/components/datadog/const.py b/homeassistant/components/datadog/const.py new file mode 100644 index 00000000000..e9e5d80eeba --- /dev/null +++ b/homeassistant/components/datadog/const.py @@ -0,0 +1,10 @@ +"""Constants for the Datadog integration.""" + +DOMAIN = "datadog" + +CONF_RATE = "rate" + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 8125 +DEFAULT_PREFIX = "hass" +DEFAULT_RATE = 1 diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index ca9681effca..815446b9ab4 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -2,6 +2,7 @@ "domain": "datadog", "name": "Datadog", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/datadog", "iot_class": "local_push", "loggers": ["datadog"], diff --git a/homeassistant/components/datadog/strings.json b/homeassistant/components/datadog/strings.json new file mode 100644 index 00000000000..86bb2019fc1 --- /dev/null +++ b/homeassistant/components/datadog/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Datadog Agent's address and port.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "prefix": "Prefix", + "rate": "Rate" + }, + "data_description": { + "host": "The hostname or IP address of the Datadog Agent.", + "port": "Port the Datadog Agent is listening on", + "prefix": "Metric prefix to use", + "rate": "The sample rate of UDP packets sent to Datadog." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "options": { + "step": { + "init": { + "description": "Update the Datadog configuration.", + "data": { + "prefix": "[%key:component::datadog::config::step::user::data::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data::rate%]" + }, + "data_description": { + "prefix": "[%key:component::datadog::config::step::user::data_description::prefix%]", + "rate": "[%key:component::datadog::config::step::user::data_description::rate%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_connection_error": { + "title": "{domain} YAML configuration import failed", + "description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 49695b695ac..d9fd32d204b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -124,6 +124,7 @@ FLOWS = { "cpuspeed", "crownstone", "daikin", + "datadog", "deako", "deconz", "deluge", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 431ece3f81a..33cc637b8a8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1171,7 +1171,7 @@ "datadog": { "name": "Datadog", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "ddwrt": { diff --git a/tests/components/datadog/common.py b/tests/components/datadog/common.py new file mode 100644 index 00000000000..07539dc0e07 --- /dev/null +++ b/tests/components/datadog/common.py @@ -0,0 +1,35 @@ +"""Common helpers for the datetime entity component tests.""" + +from unittest import mock + +MOCK_DATA = { + "host": "localhost", + "port": 8125, +} + +MOCK_OPTIONS = { + "prefix": "hass", + "rate": 1, +} + +MOCK_CONFIG = {**MOCK_DATA, **MOCK_OPTIONS} + +MOCK_YAML_INVALID = { + "host": "127.0.0.1", + "port": 65535, + "prefix": "failtest", + "rate": 1, +} + + +CONNECTION_TEST_METRIC = "connection_test" + + +def create_mock_state(entity_id, state, attributes=None): + """Helper to create a mock state object.""" + mock_state = mock.MagicMock() + mock_state.entity_id = entity_id + mock_state.state = state + mock_state.domain = entity_id.split(".")[0] + mock_state.attributes = attributes or {} + return mock_state diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py new file mode 100644 index 00000000000..7950bb2c17d --- /dev/null +++ b/tests/components/datadog/test_config_flow.py @@ -0,0 +1,229 @@ +"""Tests for the Datadog config flow.""" + +from unittest.mock import MagicMock, patch + +from homeassistant.components import datadog +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +import homeassistant.helpers.issue_registry as ir + +from .common import MOCK_CONFIG, MOCK_DATA, MOCK_OPTIONS, MOCK_YAML_INVALID + +from tests.common import MockConfigEntry + + +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test user-initiated config flow.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["title"] == f"Datadog {MOCK_CONFIG['host']}" + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == MOCK_DATA + assert result2["options"] == MOCK_OPTIONS + + +async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> None: + """Test connection failure.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("Connection failed"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, context={"source": "user"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_DATA + assert result3["options"] == MOCK_OPTIONS + + +async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test that the options flow shows an error when connection fails.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection failed"), + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + ): + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=MOCK_OPTIONS + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["data"] == MOCK_OPTIONS + + +async def test_import_flow( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers config flow and is accepted.""" + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA + assert result["options"] == MOCK_OPTIONS + + await hass.async_block_till_done() + + # Deprecation issue should be created + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_datadog" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_import_connection_error( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test import triggers connection error issue.""" + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError("connection refused"), + ): + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_YAML_INVALID, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + issue = issue_registry.async_get_issue( + datadog.DOMAIN, "deprecated_yaml_import_connection_error" + ) + assert issue is not None + assert issue.translation_key == "deprecated_yaml_import_connection_error" + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options after setup.""" + mock_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + mock_entry.add_to_hass(hass) + + new_options = { + "prefix": "updated", + "rate": 5, + } + + # OSError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=OSError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # ValueError Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd", + side_effect=ValueError, + ): + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == FlowResultType.FORM + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Success Case + with patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd: + mock_instance = MagicMock() + mock_dogstatsd.return_value = mock_instance + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=new_options + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == new_options + mock_instance.increment.assert_called_once_with("connection_test") + + +async def test_import_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort import if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": "import"}, + data=MOCK_CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 3b7bea3c926..73bce96d16c 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -4,11 +4,15 @@ from unittest import mock from unittest.mock import patch from homeassistant.components import datadog -from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON +from homeassistant.components.datadog import async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state + +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry, assert_setup_component async def test_invalid_config(hass: HomeAssistant) -> None: @@ -24,20 +28,22 @@ async def test_datadog_setup_full(hass: HomeAssistant) -> None: config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component(hass, datadog.DOMAIN, config) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=123) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 123) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch("homeassistant.components.datadog.initialize") as mock_init, - patch("homeassistant.components.datadog.statsd"), + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, ): assert await async_setup_component( hass, @@ -51,20 +57,31 @@ async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: }, ) - assert mock_init.call_count == 1 - assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=8125) + assert mock_dogstatsd.call_count == 1 + assert mock_dogstatsd.call_args == mock.call("host", 8125) async def test_logbook_entry(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, - {datadog.DOMAIN: {"host": "host", "rate": datadog.DEFAULT_RATE}}, + { + datadog.DOMAIN: { + "host": "host", + "port": datadog.DEFAULT_PORT, + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, + } + }, ) event = { @@ -79,19 +96,21 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( title="Home Assistant", - text=f"%%% \n **{event['name']}** {event['message']} \n %%%", + message=f"%%% \n **{event['name']}** {event['message']} \n %%%", tags=["entity:sensor.foo.bar", "domain:automation"], ) - mock_statsd.event.reset_mock() - async def test_state_changed(hass: HomeAssistant) -> None: """Test event listener.""" with ( - patch("homeassistant.components.datadog.initialize"), - patch("homeassistant.components.datadog.statsd") as mock_statsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_statsd_class, + patch( + "homeassistant.components.datadog.config_flow.DogStatsd", mock_statsd_class + ), ): + mock_statsd = mock_statsd_class.return_value + assert await async_setup_component( hass, datadog.DOMAIN, @@ -109,12 +128,7 @@ async def test_state_changed(hass: HomeAssistant) -> None: attributes = {"elevation": 3.2, "temperature": 5.0, "up": True, "down": False} for in_, out in valid.items(): - state = mock.MagicMock( - domain="sensor", - entity_id="sensor.foobar", - state=in_, - attributes=attributes, - ) + state = create_mock_state("sensor.foobar", in_, attributes) hass.states.async_set(state.entity_id, state.state, state.attributes) await hass.async_block_till_done() assert mock_statsd.gauge.call_count == 5 @@ -145,3 +159,56 @@ async def test_state_changed(hass: HomeAssistant) -> None: hass.states.async_set("domain.test", invalid, {}) await hass.async_block_till_done() assert not mock_statsd.gauge.called + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unloading the config entry cleans up properly.""" + client = mock.MagicMock() + + with ( + patch("homeassistant.components.datadog.DogStatsd", return_value=client), + patch("homeassistant.components.datadog.initialize"), + ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + client.flush.assert_called_once() + client.close_socket.assert_called_once() + + +async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: + """Test state_changed_listener skips None and unknown states.""" + entry = MockConfigEntry(domain=datadog.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.datadog.config_flow.DogStatsd" + ) as mock_dogstatsd, + ): + await async_setup_entry(hass, entry) + + # Test None state + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": None}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called + + # Test STATE_UNKNOWN + unknown_state = mock.MagicMock() + unknown_state.state = STATE_UNKNOWN + hass.bus.async_fire(EVENT_STATE_CHANGED, {"new_state": unknown_state}) + await hass.async_block_till_done() + assert not mock_dogstatsd.gauge.called From f481c1b92f217008958a749f3654f21d84762651 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 24 Jul 2025 19:33:34 +0900 Subject: [PATCH 0584/1118] Add sensors for ventilator in LG ThinQ (#140846) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 15 ++++++ homeassistant/components/lg_thinq/sensor.py | 40 +++++++++++++++ .../components/lg_thinq/strings.json | 51 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 02af1dec155..303660aef75 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -219,6 +219,9 @@ "total_pollution_level": { "default": "mdi:air-filter" }, + "carbon_dioxide": { + "default": "mdi:molecule-co2" + }, "monitoring_enabled": { "default": "mdi:monitor-eye" }, @@ -330,9 +333,21 @@ "hop_oil_info": { "default": "mdi:information-box-outline" }, + "hop_oil_capsule_1": { + "default": "mdi:information-box-outline" + }, + "hop_oil_capsule_2": { + "default": "mdi:information-box-outline" + }, "flavor_info": { "default": "mdi:information-box-outline" }, + "flavor_capsule_1": { + "default": "mdi:information-box-outline" + }, + "flavor_capsule_2": { + "default": "mdi:information-box-outline" + }, "beer_remain": { "default": "mdi:glass-mug-variant" }, diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 754b07cb2db..44dfd251dc6 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -75,6 +75,11 @@ AIR_QUALITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { device_class=SensorDeviceClass.ENUM, translation_key=ThinQProperty.TOTAL_POLLUTION_LEVEL, ), + ThinQProperty.CO2: SensorEntityDescription( + key=ThinQProperty.CO2, + device_class=SensorDeviceClass.ENUM, + translation_key="carbon_dioxide", + ), } BATTERY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.BATTERY_PERCENT: SensorEntityDescription( @@ -175,10 +180,30 @@ RECIPE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { key=ThinQProperty.HOP_OIL_INFO, translation_key=ThinQProperty.HOP_OIL_INFO, ), + ThinQProperty.HOP_OIL_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_1, + ), + ThinQProperty.HOP_OIL_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.HOP_OIL_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.HOP_OIL_CAPSULE_2, + ), ThinQProperty.FLAVOR_INFO: SensorEntityDescription( key=ThinQProperty.FLAVOR_INFO, translation_key=ThinQProperty.FLAVOR_INFO, ), + ThinQProperty.FLAVOR_CAPSULE_1: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_1, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_1, + ), + ThinQProperty.FLAVOR_CAPSULE_2: SensorEntityDescription( + key=ThinQProperty.FLAVOR_CAPSULE_2, + device_class=SensorDeviceClass.ENUM, + translation_key=ThinQProperty.FLAVOR_CAPSULE_2, + ), ThinQProperty.BEER_REMAIN: SensorEntityDescription( key=ThinQProperty.BEER_REMAIN, native_unit_of_measurement=PERCENTAGE, @@ -415,6 +440,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], + TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -435,7 +461,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.WORT_INFO], RECIPE_SENSOR_DESC[ThinQProperty.YEAST_INFO], RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.HOP_OIL_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_1], + RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_CAPSULE_2], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ELAPSED_DAY_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], @@ -497,6 +527,16 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], ), + DeviceType.VENTILATOR: ( + AIR_QUALITY_SENSOR_DESC[ThinQProperty.CO2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], + AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM10], + TEMPERATURE_SENSOR_DESC[ThinQProperty.CURRENT_TEMPERATURE], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], + TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], + TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 402816466ea..d0972a80127 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -333,6 +333,19 @@ "very_bad": "Poor" } }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "state": { + "invalid": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::invalid%]", + "good": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::good%]", + "normal": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "moderate": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::normal%]", + "bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "unhealthy": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::bad%]", + "very_bad": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]", + "poor": "[%key:component::lg_thinq::entity::sensor::total_pollution_level::state::very_bad%]" + } + }, "monitoring_enabled": { "name": "Air quality sensor", "state": { @@ -771,9 +784,47 @@ "hop_oil_info": { "name": "Hops" }, + "hop_oil_capsule_1": { + "name": "First hop", + "state": { + "cascade": "Cascade", + "chinook": "Chinook", + "goldings": "Goldings", + "fuggles": "Fuggles", + "hallertau": "Hallertau", + "citrussy": "Citrussy" + } + }, + "hop_oil_capsule_2": { + "name": "Second hop", + "state": { + "cascade": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::cascade%]", + "chinook": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::chinook%]", + "goldings": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::goldings%]", + "fuggles": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::fuggles%]", + "hallertau": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::hallertau%]", + "citrussy": "[%key:component::lg_thinq::entity::sensor::hop_oil_capsule_1::state::citrussy%]" + } + }, "flavor_info": { "name": "Flavor" }, + "flavor_capsule_1": { + "name": "First flavor", + "state": { + "coriander": "Coriander", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "Orange" + } + }, + "flavor_capsule_2": { + "name": "Second flavor", + "state": { + "coriander": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "coriander_seed": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::coriander%]", + "orange": "[%key:component::lg_thinq::entity::sensor::flavor_capsule_1::state::orange%]" + } + }, "beer_remain": { "name": "Recipe progress" }, From feeef8871030eb2701db5d9c7f2413a7e877c10a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 24 Jul 2025 12:07:35 +0100 Subject: [PATCH 0585/1118] Bump aiomealie to 0.10.0 (#149370) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 0aa9aa86847..804011b3d9a 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["aiomealie==0.9.6"] + "requirements": ["aiomealie==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd87da623ba..4dd2505efd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -310,7 +310,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05469693143..4b7b319ca37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,7 +292,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.6 +aiomealie==0.10.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 326bcc3f053d3297fda1c8138a02ce04bbdde65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 24 Jul 2025 14:32:51 +0200 Subject: [PATCH 0586/1118] Update aioairzone-cloud to v0.7.0 (#149369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/conftest.py | 18 ++++++++++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 41a823386e1..0747678c5a4 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.16"] + "requirements": ["aioairzone-cloud==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4dd2505efd5..dab44ec91a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7b319ca37..45b664040a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.16 +aioairzone-cloud==0.7.0 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/tests/components/airzone_cloud/conftest.py b/tests/components/airzone_cloud/conftest.py index b289efd3fb9..10388eb63d3 100644 --- a/tests/components/airzone_cloud/conftest.py +++ b/tests/components/airzone_cloud/conftest.py @@ -2,20 +2,34 @@ from unittest.mock import patch +from aioairzone_cloud.cloudapi import AirzoneCloudApi import pytest +class MockAirzoneCloudApi(AirzoneCloudApi): + """Mock AirzoneCloudApi class.""" + + async def mock_update(self: "AirzoneCloudApi"): + """Mock AirzoneCloudApi _update function.""" + await self.update_polling() + + @pytest.fixture(autouse=True) def airzone_cloud_no_websockets(): """Fixture to completely disable Airzone Cloud WebSockets.""" with ( patch( - "homeassistant.components.airzone_cloud.AirzoneCloudApi._update_websockets", - return_value=False, + "homeassistant.components.airzone_cloud.AirzoneCloudApi._update", + side_effect=MockAirzoneCloudApi.mock_update, + autospec=True, ), patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.connect_installation_websockets", return_value=None, ), + patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_websockets", + return_value=None, + ), ): yield From fea2ef1ac17cc5a41e23da3fe9fdc3e9cd033b71 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 14:37:01 +0200 Subject: [PATCH 0587/1118] Bump `imgw_pib` to version 1.5.1 (#149368) --- homeassistant/components/imgw_pib/manifest.json | 2 +- homeassistant/components/imgw_pib/strings.json | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/snapshots/test_sensor.ambr | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 79118d10de6..62a4f41ba1f 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.0"] + "requirements": ["imgw_pib==1.5.1"] } diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 7adb1673c8a..d55c134ba3b 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -25,6 +25,7 @@ "name": "Hydrological alert", "state": { "no_alert": "No alert", + "exceeding_the_warning_level": "Exceeding the warning level", "hydrological_drought": "Hydrological drought", "rapid_water_level_rise": "Rapid water level rise" }, @@ -41,6 +42,7 @@ "options": { "state": { "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "exceeding_the_warning_level": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::exceeding_the_warning_level%]", "hydrological_drought": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::hydrological_drought%]", "rapid_water_level_rise": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::rapid_water_level_rise%]" } diff --git a/requirements_all.txt b/requirements_all.txt index dab44ec91a8..3cecc30d6a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,7 +1237,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45b664040a2..07514077adc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1071,7 +1071,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.0 +imgw_pib==1.5.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 276ea41eecf..cdefd949560 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -9,6 +9,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), }), 'config_entry_id': , @@ -51,6 +52,7 @@ 'no_alert', 'hydrological_drought', 'rapid_water_level_rise', + 'exceeding_the_warning_level', ]), 'probability': 80, 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), From dd3c9ab3afba11afab0eae5889dc793839e7ace0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 24 Jul 2025 15:34:00 +0200 Subject: [PATCH 0588/1118] Use OptionsFlowWithReload in mqtt (#149092) --- homeassistant/components/mqtt/__init__.py | 11 ----------- homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 16 +++++++--------- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9e3dc59f852..4f00c4da958 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -246,14 +246,6 @@ MQTT_PUBLISH_SCHEMA = vol.Schema( ) -async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle signals of config entry being updated. - - Causes for this is config entry options changing. - """ - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" @@ -435,9 +427,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data.subscriptions_to_restore ) mqtt_data.subscriptions_to_restore = set() - mqtt_data.reload_dispatchers.append( - entry.add_update_listener(_async_config_entry_updated) - ) return (mqtt_data, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 52f00c82c27..023872d410c 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.const import ( @@ -2537,7 +2537,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(OptionsFlow): +class MQTTOptionsFlowHandler(OptionsFlowWithReload): """Handle MQTT options.""" async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: @@ -3353,7 +3353,7 @@ def _validate_pki_file( async def async_get_broker_settings( # noqa: C901 - flow: ConfigFlow | OptionsFlow, + flow: ConfigFlow | OptionsFlowWithReload, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ce0a0c44a79..b45a4a66aa9 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,7 +17,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.config_flow import ( + PWD_NOT_CHANGED, + MQTTOptionsFlowHandler, +) from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -193,8 +196,8 @@ def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, Magi @pytest.fixture def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" - with patch( - "homeassistant.components.mqtt._async_config_entry_updated" + with patch.object( + MQTTOptionsFlowHandler, "automatic_reload", return_value=False ) as mock_reload: yield mock_reload @@ -1330,11 +1333,11 @@ async def test_keepalive_validation( assert result["reason"] == "reconfigure_successful" +@pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_disable_birth_will( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, - mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" await mqtt_mock_entry() @@ -1348,7 +1351,6 @@ async def test_disable_birth_will( }, ) await hass.async_block_till_done() - mock_reload_after_entry_update.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1387,10 +1389,6 @@ async def test_disable_birth_will( mqtt.CONF_WILL_MESSAGE: {}, } - await hass.async_block_till_done() - # assert that the entry was reloaded with the new config - assert mock_reload_after_entry_update.call_count == 1 - async def test_invalid_discovery_prefix( hass: HomeAssistant, From d6175fb3835b449f58b3dfaf0b9a3b8da6993632 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:05:24 +0200 Subject: [PATCH 0589/1118] Update mypy-dev to 1.18.0a3 (#149383) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b0affc56113..cc9eff9dc3f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a2 +mypy-dev==1.18.0a3 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From a0992498c6427ac0b12b4ac64a006b0d19551cc2 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:52:43 +0200 Subject: [PATCH 0590/1118] Improve removal of stale entities/devices in Husqvarna Automower (#148428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../husqvarna_automower/coordinator.py | 209 ++++++++---------- 1 file changed, 98 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 7fc1e628e27..91adc8c75ec 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -58,9 +58,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] - self._devices_last_update: set[str] = set() - self._zones_last_update: dict[str, set[str]] = {} - self._areas_last_update: dict[str, set[int]] = {} @override @callback @@ -87,11 +84,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Handle data updates and process dynamic entity management.""" if self.data is not None: self._async_add_remove_devices() - for mower_id in self.data: - if self.data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones() - if self.data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas() + if any( + mower_data.capabilities.stay_out_zones + for mower_data in self.data.values() + ): + self._async_add_remove_stay_out_zones() + if any( + mower_data.capabilities.work_areas for mower_data in self.data.values() + ): + self._async_add_remove_work_areas() @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,44 +162,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): ) def _async_add_remove_devices(self) -> None: - """Add new device, remove non-existing device.""" + """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) - - # Skip update if no changes - if current_devices == self._devices_last_update: - return - - # Process removed devices - removed_devices = self._devices_last_update - current_devices - if removed_devices: - _LOGGER.debug("Removed devices: %s", ", ".join(map(str, removed_devices))) - self._remove_device(removed_devices) - - # Process new device - new_devices = current_devices - self._devices_last_update - if new_devices: - _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) - self._add_new_devices(new_devices) - - # Update device state - self._devices_last_update = current_devices - - def _remove_device(self, removed_devices: set[str]) -> None: - """Remove device from the registry.""" device_registry = dr.async_get(self.hass) - for mower_id in removed_devices: - if device := device_registry.async_get_device( - identifiers={(DOMAIN, str(mower_id))} - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - def _add_new_devices(self, new_devices: set[str]) -> None: - """Add new device and trigger callbacks.""" - for mower_callback in self.new_devices_callbacks: - mower_callback(new_devices) + registered_devices: set[str] = { + str(mower_id) + for device in device_registry.devices.get_devices_for_config_entry_id( + self.config_entry.entry_id + ) + for domain, mower_id in device.identifiers + if domain == DOMAIN + } + + orphaned_devices = registered_devices - current_devices + if orphaned_devices: + _LOGGER.debug("Removing orphaned devices: %s", orphaned_devices) + device_registry = dr.async_get(self.hass) + for mower_id in orphaned_devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, mower_id)}) + if dev is not None: + device_registry.async_update_device( + device_id=dev.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + new_devices = current_devices - registered_devices + if new_devices: + _LOGGER.debug("New devices found: %s", new_devices) + for mower_callback in self.new_devices_callbacks: + mower_callback(new_devices) def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" @@ -209,42 +202,39 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): and mower_data.stay_out_zones is not None } - if not self._zones_last_update: - self._zones_last_update = current_zones - return - - if current_zones == self._zones_last_update: - return - - self._zones_last_update = self._update_stay_out_zones(current_zones) - - def _update_stay_out_zones( - self, current_zones: dict[str, set[str]] - ) -> dict[str, set[str]]: - """Update stay-out zones by adding and removing as needed.""" - new_zones = { - mower_id: zones - self._zones_last_update.get(mower_id, set()) - for mower_id, zones in current_zones.items() - } - removed_zones = { - mower_id: self._zones_last_update.get(mower_id, set()) - zones - for mower_id, zones in current_zones.items() - } - - for mower_id, zones in new_zones.items(): - for zone_callback in self.new_zones_callbacks: - zone_callback(mower_id, set(zones)) - entity_registry = er.async_get(self.hass) - for mower_id, zones in removed_zones.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for zone in zones: - if entity_entry.unique_id.startswith(f"{mower_id}_{zone}"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_zones + registered_zones: dict[str, set[str]] = {} + for mower_id in self.data: + registered_zones[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_stay_out_zones"): + zone_id = uid.removeprefix(f"{mower_id}_").removesuffix( + "_stay_out_zones" + ) + registered_zones[mower_id].add(zone_id) + + for mower_id, current_ids in current_zones.items(): + known_ids = registered_zones.get(mower_id, set()) + + new_zones = current_ids - known_ids + removed_zones = known_ids - current_ids + + if new_zones: + _LOGGER.debug("New stay-out zones: %s", new_zones) + for zone_callback in self.new_zones_callbacks: + zone_callback(mower_id, new_zones) + + if removed_zones: + _LOGGER.debug("Removing stay-out zones: %s", removed_zones) + for entry in entries: + for zone_id in removed_zones: + if entry.unique_id == f"{mower_id}_{zone_id}_stay_out_zones": + entity_registry.async_remove(entry.entity_id) def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" @@ -254,39 +244,36 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): if mower_data.capabilities.work_areas and mower_data.work_areas is not None } - if not self._areas_last_update: - self._areas_last_update = current_areas - return - - if current_areas == self._areas_last_update: - return - - self._areas_last_update = self._update_work_areas(current_areas) - - def _update_work_areas( - self, current_areas: dict[str, set[int]] - ) -> dict[str, set[int]]: - """Update work areas by adding and removing as needed.""" - new_areas = { - mower_id: areas - self._areas_last_update.get(mower_id, set()) - for mower_id, areas in current_areas.items() - } - removed_areas = { - mower_id: self._areas_last_update.get(mower_id, set()) - areas - for mower_id, areas in current_areas.items() - } - - for mower_id, areas in new_areas.items(): - for area_callback in self.new_areas_callbacks: - area_callback(mower_id, set(areas)) - entity_registry = er.async_get(self.hass) - for mower_id, areas in removed_areas.items(): - for entity_entry in er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - for area in areas: - if entity_entry.unique_id.startswith(f"{mower_id}_{area}_"): - entity_registry.async_remove(entity_entry.entity_id) + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) - return current_areas + registered_areas: dict[str, set[int]] = {} + for mower_id in self.data: + registered_areas[mower_id] = set() + for entry in entries: + uid = entry.unique_id + if uid.startswith(f"{mower_id}_") and uid.endswith("_work_area"): + parts = uid.removeprefix(f"{mower_id}_").split("_") + area_id_str = parts[0] if parts else None + if area_id_str and area_id_str.isdigit(): + registered_areas[mower_id].add(int(area_id_str)) + + for mower_id, current_ids in current_areas.items(): + known_ids = registered_areas.get(mower_id, set()) + + new_areas = current_ids - known_ids + removed_areas = known_ids - current_ids + + if new_areas: + _LOGGER.debug("New work areas: %s", new_areas) + for area_callback in self.new_areas_callbacks: + area_callback(mower_id, new_areas) + + if removed_areas: + _LOGGER.debug("Removing work areas: %s", removed_areas) + for entry in entries: + for area_id in removed_areas: + if entry.unique_id.startswith(f"{mower_id}_{area_id}_"): + entity_registry.async_remove(entry.entity_id) From 6adcd3452151d77b2359f612431ca9117c1ee3cc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 17:10:46 +0200 Subject: [PATCH 0591/1118] Remove space character from "autodetect" in `xiaomi_miio` (#149381) --- homeassistant/components/xiaomi_miio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index fef185daf41..00e11224649 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -311,7 +311,7 @@ "name": "Learn mode" }, "auto_detect": { - "name": "Auto detect" + "name": "Autodetect" }, "ionizer": { "name": "Ionizer" From 760b69d458b6b162c67d9665b28ebdbc18334152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 24 Jul 2025 18:13:54 +0300 Subject: [PATCH 0592/1118] Only send integers when setting Huum sauna temperature (#149380) --- homeassistant/components/huum/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 6a50137f0a7..af4e8cc3623 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -89,7 +89,10 @@ class HuumDevice(HuumBaseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" if hvac_mode == HVACMode.HEAT: - await self._turn_on(self.target_temperature) + # Make sure to send integers + # The temperature is not always an integer if the user uses Fahrenheit + temperature = int(self.target_temperature) + await self._turn_on(temperature) elif hvac_mode == HVACMode.OFF: await self.coordinator.huum.turn_off() await self.coordinator.async_refresh() @@ -99,6 +102,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None or self.hvac_mode != HVACMode.HEAT: return + temperature = int(temperature) await self._turn_on(temperature) await self.coordinator.async_refresh() From 8b8616182dc4bab268bde63968463d7136c29621 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 24 Jul 2025 17:27:02 +0200 Subject: [PATCH 0593/1118] Allow downloading a device analytics dump (#149376) --- .../components/analytics/__init__.py | 3 + .../components/analytics/analytics.py | 89 +++++++++++++- homeassistant/components/analytics/http.py | 27 ++++ .../components/analytics/manifest.json | 2 +- tests/components/analytics/test_analytics.py | 116 +++++++++++++++++- 5 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/analytics/http.py diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 0df3b8138e2..83610f0dc75 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -14,6 +14,7 @@ from homeassistant.util.hass_dict import HassKey from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +from .http import AnalyticsDevicesView CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -55,6 +56,8 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_analytics) websocket_api.async_register_command(hass, websocket_analytics_preferences) + hass.http.register_view(AnalyticsDevicesView) + hass.data[DATA_COMPONENT] = analytics return True diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1a07a8abd0f..8a2a182c796 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store @@ -77,6 +77,11 @@ from .const import ( ) +def gen_uuid() -> str: + """Generate a new UUID.""" + return uuid.uuid4().hex + + @dataclass class AnalyticsData: """Analytics data.""" @@ -184,7 +189,7 @@ class Analytics: return if self._data.uuid is None: - self._data.uuid = uuid.uuid4().hex + self._data.uuid = gen_uuid() await self._store.async_save(dataclass_asdict(self._data)) if self.supervisor: @@ -381,3 +386,83 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: ).values(): domains.update(platforms) return domains + + +async def async_devices_payload(hass: HomeAssistant) -> dict: + """Return the devices payload.""" + integrations_without_model_id: set[str] = set() + devices: list[dict[str, Any]] = [] + dev_reg = dr.async_get(hass) + # Devices that need via device info set + new_indexes: dict[str, int] = {} + via_devices: dict[str, str] = {} + + seen_integrations = set() + + for device in dev_reg.devices.values(): + # Ignore services + if device.entry_type: + continue + + if not device.primary_config_entry: + continue + + config_entry = hass.config_entries.async_get_entry(device.primary_config_entry) + + if config_entry is None: + continue + + seen_integrations.add(config_entry.domain) + + if not device.model_id: + integrations_without_model_id.add(config_entry.domain) + continue + + if not device.manufacturer: + continue + + new_indexes[device.id] = len(devices) + devices.append( + { + "integration": config_entry.domain, + "manufacturer": device.manufacturer, + "model_id": device.model_id, + "model": device.model, + "sw_version": device.sw_version, + "hw_version": device.hw_version, + "has_suggested_area": device.suggested_area is not None, + "has_configuration_url": device.configuration_url is not None, + "via_device": None, + } + ) + if device.via_device_id: + via_devices[device.id] = device.via_device_id + + for from_device, via_device in via_devices.items(): + if via_device not in new_indexes: + continue + devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device] + + integrations = { + domain: integration + for domain, integration in ( + await async_get_integrations(hass, seen_integrations) + ).items() + if isinstance(integration, Integration) + } + + for device_info in devices: + if integration := integrations.get(device_info["integration"]): + device_info["is_custom_integration"] = not integration.is_built_in + + return { + "version": "home-assistant:1", + "no_model_id": sorted( + [ + domain + for domain in integrations_without_model_id + if domain in integrations and integrations[domain].is_built_in + ] + ), + "devices": devices, + } diff --git a/homeassistant/components/analytics/http.py b/homeassistant/components/analytics/http.py new file mode 100644 index 00000000000..a91b373bc45 --- /dev/null +++ b/homeassistant/components/analytics/http.py @@ -0,0 +1,27 @@ +"""HTTP endpoints for analytics integration.""" + +from aiohttp import web + +from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin +from homeassistant.core import HomeAssistant + +from .analytics import async_devices_payload + + +class AnalyticsDevicesView(HomeAssistantView): + """View to handle analytics devices payload download requests.""" + + url = "/api/analytics/devices" + name = "api:analytics:devices" + + @require_admin + async def get(self, request: web.Request) -> web.Response: + """Return analytics devices payload as JSON.""" + hass: HomeAssistant = request.app[KEY_HASS] + payload = await async_devices_payload(hass) + return self.json( + payload, + headers={ + "Content-Disposition": "attachment; filename=analytics_devices.json" + }, + ) diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 5142a86ad97..ab51ed31c9e 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -3,7 +3,7 @@ "name": "Analytics", "after_dependencies": ["energy", "hassio", "recorder"], "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "dependencies": ["api", "websocket_api", "http"], "documentation": "https://www.home-assistant.io/integrations/analytics", "integration_type": "system", "iot_class": "cloud_push", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 01d08572197..90f3049d8fd 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1,8 +1,9 @@ """The tests for the analytics .""" from collections.abc import Generator +from http import HTTPStatus from typing import Any -from unittest.mock import AsyncMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp from awesomeversion import AwesomeVersion @@ -10,7 +11,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type -from homeassistant.components.analytics.analytics import Analytics +from homeassistant.components.analytics.analytics import ( + Analytics, + async_devices_payload, +) from homeassistant.components.analytics.const import ( ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL_DEV, @@ -22,11 +26,13 @@ from homeassistant.components.analytics.const import ( from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator MOCK_UUID = "abcdefg" MOCK_VERSION = "1970.1.0" @@ -37,8 +43,9 @@ MOCK_VERSION_NIGHTLY = "1970.1.0.dev19700101" @pytest.fixture(autouse=True) def uuid_mock() -> Generator[None]: """Mock the UUID.""" - with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: - hex_mock.return_value = MOCK_UUID + with patch( + "homeassistant.components.analytics.analytics.gen_uuid", return_value=MOCK_UUID + ): yield @@ -966,3 +973,104 @@ async def test_submitting_legacy_integrations( assert submitted_data["integrations"] == ["legacy_binary_sensor"] assert submitted_data == logged_data assert snapshot == submitted_data + + +async def test_devices_payload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test devices payload.""" + assert await async_setup_component(hass, "analytics", {}) + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "no_model_id": [], + "devices": [], + } + + mock_config_entry = MockConfigEntry(domain="hue") + mock_config_entry.add_to_hass(hass) + + # Normal entry + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "1")}, + sw_version="test-sw-version", + hw_version="test-hw-version", + name="test-name", + manufacturer="test-manufacturer", + model="test-model", + model_id="test-model-id", + suggested_area="Game Room", + configuration_url="http://example.com/config", + ) + + # Ignored because service type + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "2")}, + manufacturer="test-manufacturer", + model_id="test-model-id", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # Ignored because no model id + no_model_id_config_entry = MockConfigEntry(domain="no_model_id") + no_model_id_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=no_model_id_config_entry.entry_id, + identifiers={("device", "4")}, + manufacturer="test-manufacturer", + ) + + # Ignored because no manufacturer + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "5")}, + model_id="test-model-id", + ) + + # Entry with via device + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("device", "6")}, + manufacturer="test-manufacturer6", + model_id="test-model-id6", + via_device=("device", "1"), + ) + + assert await async_devices_payload(hass) == { + "version": "home-assistant:1", + "no_model_id": [], + "devices": [ + { + "manufacturer": "test-manufacturer", + "model_id": "test-model-id", + "model": "test-model", + "sw_version": "test-sw-version", + "hw_version": "test-hw-version", + "integration": "hue", + "is_custom_integration": False, + "has_suggested_area": True, + "has_configuration_url": True, + "via_device": None, + }, + { + "manufacturer": "test-manufacturer6", + "model_id": "test-model-id6", + "model": None, + "sw_version": None, + "hw_version": None, + "integration": "hue", + "is_custom_integration": False, + "has_suggested_area": False, + "has_configuration_url": False, + "via_device": 0, + }, + ], + } + + client = await hass_client() + response = await client.get("/api/analytics/devices") + assert response.status == HTTPStatus.OK + assert await response.json() == await async_devices_payload(hass) From ef7cd815b287180c18544ec554233dc033ca2d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 24 Jul 2025 16:52:12 +0100 Subject: [PATCH 0594/1118] Add list of targeted entities to target state event (#149203) --- homeassistant/helpers/target.py | 18 +++++++++++++----- tests/helpers/test_target.py | 12 +++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 239d1e66336..0b902ea4d23 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -40,6 +40,14 @@ from .typing import ConfigType _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass(slots=True, frozen=True) +class TargetStateChangedData: + """Data for state change events related to targets.""" + + state_change_event: Event[EventStateChangedData] + targeted_entity_ids: set[str] + + def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" return ids not in (None, ENTITY_MATCH_NONE) @@ -259,7 +267,7 @@ class TargetStateChangeTracker: self, hass: HomeAssistant, selector_data: TargetSelectorData, - action: Callable[[Event[EventStateChangedData]], Any], + action: Callable[[TargetStateChangedData], Any], ) -> None: """Initialize the state change tracker.""" self._hass = hass @@ -281,6 +289,8 @@ class TargetStateChangeTracker: self._hass, self._selector_data, expand_group=False ) + tracked_entities = selected.referenced.union(selected.indirectly_referenced) + @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: """Handle state change events.""" @@ -288,9 +298,7 @@ class TargetStateChangeTracker: event.data["entity_id"] in selected.referenced or event.data["entity_id"] in selected.indirectly_referenced ): - self._action(event) - - tracked_entities = selected.referenced.union(selected.indirectly_referenced) + self._action(TargetStateChangedData(event, tracked_entities)) _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) self._state_change_unsub = async_track_state_change_event( @@ -339,7 +347,7 @@ class TargetStateChangeTracker: def async_track_target_selector_state_change_event( hass: HomeAssistant, target_selector_config: ConfigType, - action: Callable[[Event[EventStateChangedData]], Any], + action: Callable[[TargetStateChangedData], Any], ) -> CALLBACK_TYPE: """Track state changes for entities referenced directly or indirectly in a target selector.""" selector_data = TargetSelectorData(target_selector_config) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index c87a320e378..fa31ef375fd 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -14,7 +14,7 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -482,10 +482,10 @@ async def test_async_track_target_selector_state_change_event( hass: HomeAssistant, ) -> None: """Test async_track_target_selector_state_change_event with multiple targets.""" - events: list[Event[EventStateChangedData]] = [] + events: list[target.TargetStateChangedData] = [] @callback - def state_change_callback(event: Event[EventStateChangedData]): + def state_change_callback(event: target.TargetStateChangedData): """Handle state change events.""" events.append(event) @@ -504,8 +504,10 @@ async def test_async_track_target_selector_state_change_event( assert len(events) == len(entities_to_assert_change) entities_seen = set() for event in events: - entities_seen.add(event.data["entity_id"]) - assert event.data["new_state"].state == last_state + state_change_event = event.state_change_event + entities_seen.add(state_change_event.data["entity_id"]) + assert state_change_event.data["new_state"].state == last_state + assert event.targeted_entity_ids == set(entities_to_assert_change) assert entities_seen == set(entities_to_assert_change) events.clear() From 995a99e25640a0fe6efddb91cdf22ea9dbec626a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 24 Jul 2025 18:54:00 +0300 Subject: [PATCH 0595/1118] Bump aioamazondevices to 3.5.1 (#149385) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 9a98be052be..74187ba7ed4 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.5.0"] + "requirements": ["aioamazondevices==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3cecc30d6a8..9c0bb3df3a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.0 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07514077adc..b028a880bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.0 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 56d97f5545346a6b4ed146d5977e246d3900e12d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 18:49:46 +0200 Subject: [PATCH 0596/1118] Drop duplicated lower-case "qnap" from setup description (#149384) --- homeassistant/components/qnap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 0d82443da11..1979be3e827 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to the QNAP device", - "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", + "description": "This sensor allows getting various statistics from your QNAP NAS.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From eeca5a80302875378d778a8b417307f0d0ac868e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20=27Horm=27=20Hor=C3=A1k?= Date: Thu, 24 Jul 2025 19:10:01 +0200 Subject: [PATCH 0597/1118] Improve Airthings test coverage (#144750) Co-authored-by: Joostlek --- .../components/airthings/config_flow.py | 5 +- homeassistant/components/airthings/sensor.py | 4 +- tests/components/airthings/__init__.py | 11 + tests/components/airthings/conftest.py | 79 + .../airthings/fixtures/device_view_plus.json | 19 + .../fixtures/device_wave_enhance.json | 18 + .../airthings/fixtures/device_wave_plus.json | 17 + .../airthings/snapshots/test_sensor.ambr | 1352 +++++++++++++++++ .../components/airthings/test_config_flow.py | 166 +- tests/components/airthings/test_sensor.py | 23 + 10 files changed, 1591 insertions(+), 103 deletions(-) create mode 100644 tests/components/airthings/conftest.py create mode 100644 tests/components/airthings/fixtures/device_view_plus.json create mode 100644 tests/components/airthings/fixtures/device_wave_enhance.json create mode 100644 tests/components/airthings/fixtures/device_wave_plus.json create mode 100644 tests/components/airthings/snapshots/test_sensor.ambr create mode 100644 tests/components/airthings/test_sensor.py diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index ab453ede20c..23711b7a9a2 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -45,6 +45,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): ) errors = {} + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() try: await airthings.get_token( @@ -60,9 +62,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Airthings", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index ff30fb2f2ae..45e532268c0 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,7 +150,7 @@ async def async_setup_entry( coordinator = entry.runtime_data entities = [ - AirthingsHeaterEnergySensor( + AirthingsDeviceSensor( coordinator, airthings_device, SENSORS[sensor_types], @@ -162,7 +162,7 @@ async def async_setup_entry( async_add_entities(entities) -class AirthingsHeaterEnergySensor( +class AirthingsDeviceSensor( CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity ): """Representation of a Airthings Sensor device.""" diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py index e331fb2f2c6..0d2c58c22ae 100644 --- a/tests/components/airthings/__init__.py +++ b/tests/components/airthings/__init__.py @@ -1 +1,12 @@ """Tests for the Airthings integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airthings/conftest.py b/tests/components/airthings/conftest.py new file mode 100644 index 00000000000..4c67e35108c --- /dev/null +++ b/tests/components/airthings/conftest.py @@ -0,0 +1,79 @@ +"""Airthings test configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from airthings import Airthings, AirthingsDevice +import pytest + +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ID: "client_id", + CONF_SECRET: "secret", + }, + unique_id="client_id", + ) + + +@pytest.fixture(params=["view_plus", "wave_plus", "wave_enhance"]) +def airthings_fixture( + request: pytest.FixtureRequest, +) -> str: + """Return the fixture name for Airthings device types.""" + return request.param + + +@pytest.fixture +def mock_airthings_device(airthings_fixture: str) -> AirthingsDevice: + """Mock an Airthings device.""" + return AirthingsDevice( + **load_json_object_fixture(f"device_{airthings_fixture}.json", DOMAIN) + ) + + +@pytest.fixture +def mock_airthings_client( + mock_airthings_device: AirthingsDevice, mock_airthings_token: AsyncMock +) -> Generator[Airthings]: + """Mock an Airthings client.""" + with patch( + "homeassistant.components.airthings.Airthings", + autospec=True, + ) as mock_airthings: + client = mock_airthings.return_value + client.update_devices.return_value = { + mock_airthings_device.device_id: mock_airthings_device + } + yield client + + +@pytest.fixture +def mock_airthings_token() -> Generator[Airthings]: + """Mock an Airthings client.""" + with ( + patch( + "homeassistant.components.airthings.config_flow.airthings.get_token", + return_value="test_token", + ) as mock_get_token, + ): + yield mock_get_token + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airthings/fixtures/device_view_plus.json b/tests/components/airthings/fixtures/device_view_plus.json new file mode 100644 index 00000000000..194b0493d2e --- /dev/null +++ b/tests/components/airthings/fixtures/device_view_plus.json @@ -0,0 +1,19 @@ +{ + "device_id": "2960000001", + "name": "Living Room", + "is_active": true, + "device_type": "VIEW_PLUS", + "product_name": "View Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pm1": 4.4, + "pm25": 5.5, + "pressure": 6.6, + "radonShortTermAvg": 7.7, + "temp": 8.8, + "voc": 9.9 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_enhance.json b/tests/components/airthings/fixtures/device_wave_enhance.json new file mode 100644 index 00000000000..06c7c489ad1 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_enhance.json @@ -0,0 +1,18 @@ +{ + "device_id": "3210000003", + "name": "Bedroom", + "is_active": true, + "device_type": "WAVE_ENHANCE", + "product_name": "Wave Enhance", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "lux": 4.4, + "pressure": 5.5, + "sla": 6.6, + "temp": 7.7, + "voc": 8.8 + } +} diff --git a/tests/components/airthings/fixtures/device_wave_plus.json b/tests/components/airthings/fixtures/device_wave_plus.json new file mode 100644 index 00000000000..0acf09daa62 --- /dev/null +++ b/tests/components/airthings/fixtures/device_wave_plus.json @@ -0,0 +1,17 @@ +{ + "device_id": "2930000002", + "name": "Office", + "is_active": true, + "device_type": "WAVE_PLUS", + "product_name": "Wave Plus", + "location_name": "Home", + "sensors": { + "battery": 1.1, + "co2": 2.2, + "humidity": 3.3, + "pressure": 4.4, + "radonShortTermAvg": 5.5, + "temp": 6.6, + "voc": 7.7 + } +} diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..67a210ca037 --- /dev/null +++ b/tests/components/airthings/snapshots/test_sensor.ambr @@ -0,0 +1,1352 @@ +# serializer version: 1 +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-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.living_room_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Living Room Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_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.living_room_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-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.living_room_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Living Room Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.living_room_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_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.living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Living Room Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-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.living_room_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Living Room PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-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.living_room_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_pm25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Living Room PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-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.living_room_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2960000001_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.living_room_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_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.living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Room Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-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.living_room_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2960000001_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[view_plus][sensor.living_room_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Living Room Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.living_room_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.9', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-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.bedroom_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Bedroom Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_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.bedroom_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bedroom Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-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.bedroom_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Bedroom Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.bedroom_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_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.bedroom_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Bedroom Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bedroom_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-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.bedroom_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_lux', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Bedroom Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.bedroom_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-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.bedroom_sound_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sound pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_sla', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_sound_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Bedroom Sound pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_sound_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_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.bedroom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bedroom Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bedroom_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-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.bedroom_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3210000003_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_enhance][sensor.bedroom_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Bedroom Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.bedroom_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.8', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-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.office_atmospheric_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Atmospheric pressure', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'Office Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_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.office_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-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.office_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Office Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.office_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_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.office_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Office Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.office_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-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.office_radon', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Radon', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radon', + 'unique_id': '2930000002_radonShortTermAvg', + 'unit_of_measurement': 'Bq/m³', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_radon-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Radon', + 'state_class': , + 'unit_of_measurement': 'Bq/m³', + }), + 'context': , + 'entity_id': 'sensor.office_radon', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_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.office_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.6', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-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.office_volatile_organic_compounds_parts', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds parts', + 'platform': 'airthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2930000002_voc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_all_device_types[wave_plus][sensor.office_volatile_organic_compounds_parts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Office Volatile organic compounds parts', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.office_volatile_organic_compounds_parts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.7', + }) +# --- diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index ac42eddf769..f8791df0c26 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -1,12 +1,12 @@ """Test the Airthings config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import airthings import pytest -from homeassistant import config_entries from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,108 +38,87 @@ DHCP_SERVICE_INFO = [ ] -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_airthings_token: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we get the full flow working.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch( - "airthings.get_token", - return_value="test_token", - ), - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == "client_id" assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("exception", "error"), + [ + (airthings.AirthingsAuthError, "invalid_auth"), + (airthings.AirthingsConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle exceptions correctly.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsAuthError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + mock_airthings_token.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=airthings.AirthingsConnectionError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} + mock_airthings_token.side_effect = None -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, ) - with patch( - "airthings.get_token", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: +async def test_flow_entry_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test user input for config_entry that already exists.""" + mock_config_entry.add_to_hass(hass) - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - first_entry.add_to_hass(hass) - with patch("airthings.get_token", return_value="token"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA - ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -147,54 +126,45 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @pytest.mark.parametrize("dhcp_service_info", DHCP_SERVICE_INFO) async def test_dhcp_flow( - hass: HomeAssistant, dhcp_service_info: DhcpServiceInfo + hass: HomeAssistant, + dhcp_service_info: DhcpServiceInfo, + mock_airthings_token: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test the DHCP discovery flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=dhcp_service_info, - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with ( - patch( - "homeassistant.components.airthings.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "airthings.get_token", - return_value="test_token", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_DATA, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Airthings" assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_DATA[CONF_ID] assert len(mock_setup_entry.mock_calls) == 1 -async def test_dhcp_flow_hub_already_configured(hass: HomeAssistant) -> None: +async def test_dhcp_flow_hub_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that DHCP discovery fails when already configured.""" - first_entry = MockConfigEntry( - domain="airthings", - data=TEST_DATA, - unique_id=TEST_DATA[CONF_ID], - ) - first_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, + context={"source": SOURCE_DHCP}, data=DHCP_SERVICE_INFO[0], - context={"source": config_entries.SOURCE_DHCP}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/airthings/test_sensor.py b/tests/components/airthings/test_sensor.py new file mode 100644 index 00000000000..d78d3356244 --- /dev/null +++ b/tests/components/airthings/test_sensor.py @@ -0,0 +1,23 @@ +"""Test the Airthings sensors.""" + +from airthings import Airthings +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_device_types( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_airthings_client: Airthings, + entity_registry: er.EntityRegistry, +) -> None: + """Test all device types.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From f2c995cf86acf0221fe93cd997372f4c8be3abc1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 19:20:28 +0200 Subject: [PATCH 0598/1118] Fix sentence-casing of "DSMR options" string (#149392) --- homeassistant/components/dsmr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index e95e9ae870a..7fbfcd573ed 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -222,7 +222,7 @@ "data": { "time_between_update": "Minimum time between entity updates [s]" }, - "title": "DSMR Options" + "title": "DSMR options" } } } From 36a98470cc12869e074b71f955e81df5d84e3bbb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 19:20:42 +0200 Subject: [PATCH 0599/1118] Remove excessive comma from `dsmr_reader` issue description (#149393) --- homeassistant/components/dsmr_reader/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index d405898a393..6f8bcde12f4 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -263,7 +263,7 @@ "issues": { "cannot_subscribe_mqtt_topic": { "title": "Cannot subscribe to MQTT topic {topic_title}", - "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running, before starting this integration." + "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running before starting this integration." } } } From 5c7913c3bdeea507b15928fa4abbdac6d9789700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 24 Jul 2025 19:07:57 +0100 Subject: [PATCH 0600/1118] Remove door state from Whirlpool machine state sensor (#144078) --- homeassistant/components/whirlpool/sensor.py | 9 ----- .../components/whirlpool/strings.json | 6 ++-- .../whirlpool/snapshots/test_sensor.ambr | 4 --- tests/components/whirlpool/test_sensor.py | 33 ------------------- 4 files changed, 2 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 164e1b6e5fe..1bb825cc18f 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -86,15 +86,11 @@ STATE_CYCLE_SENSING = "cycle_sensing" STATE_CYCLE_SOAKING = "cycle_soaking" STATE_CYCLE_SPINNING = "cycle_spinning" STATE_CYCLE_WASHING = "cycle_washing" -STATE_DOOR_OPEN = "door_open" def washer_state(washer: Washer) -> str | None: """Determine correct states for a washer.""" - if washer.get_door_open(): - return STATE_DOOR_OPEN - machine_state = washer.get_machine_state() if machine_state == WasherMachineState.RunningMainCycle: @@ -117,9 +113,6 @@ def washer_state(washer: Washer) -> str | None: def dryer_state(dryer: Dryer) -> str | None: """Determine correct states for a dryer.""" - if dryer.get_door_open(): - return STATE_DOOR_OPEN - machine_state = dryer.get_machine_state() if machine_state == DryerMachineState.RunningMainCycle: @@ -144,13 +137,11 @@ WASHER_STATE_OPTIONS = [ STATE_CYCLE_SOAKING, STATE_CYCLE_SPINNING, STATE_CYCLE_WASHING, - STATE_DOOR_OPEN, ] DRYER_STATE_OPTIONS = [ *DRYER_MACHINE_STATE.values(), STATE_CYCLE_SENSING, - STATE_DOOR_OPEN, ] WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 27e5ebe3ea9..9f214bf204f 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -74,8 +74,7 @@ "cycle_sensing": "Cycle sensing", "cycle_soaking": "Cycle soaking", "cycle_spinning": "Cycle spinning", - "cycle_washing": "Cycle washing", - "door_open": "Door open" + "cycle_washing": "Cycle washing" } }, "dryer_state": { @@ -105,8 +104,7 @@ "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%]" + "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]" } }, "whirlpool_tank": { diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index fa67b5ecc05..64b513abe4e 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -77,7 +77,6 @@ 'system_initialize', 'cancelled', 'cycle_sensing', - 'door_open', ]), }), 'config_entry_id': , @@ -136,7 +135,6 @@ 'system_initialize', 'cancelled', 'cycle_sensing', - 'door_open', ]), }), 'context': , @@ -293,7 +291,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'config_entry_id': , @@ -356,7 +353,6 @@ 'cycle_soaking', 'cycle_spinning', 'cycle_washing', - 'door_open', ]), }), 'context': , diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index eaed27c95f8..85f0940fc4e 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -296,39 +296,6 @@ async def test_washer_running_states( assert state.state == expected_state -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) -async def test_washer_dryer_door_open_state( - hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - request: pytest.FixtureRequest, -) -> None: - """Test Washer/Dryer machine state when door is open.""" - mock_instance = request.getfixturevalue(mock_fixture) - await init_integration(hass) - - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - mock_instance.get_door_open.return_value = True - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "door_open" - - mock_instance.get_door_open.return_value = False - - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) - assert state.state == "running_maincycle" - - @pytest.mark.parametrize( ("entity_id", "mock_fixture", "mock_method_name", "values"), [ From 5c4862ffe15541b5a6b36969891e323bc79ff28f Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 25 Jul 2025 03:12:41 +0900 Subject: [PATCH 0601/1118] Fix Air Conditioner set temperature error in LG ThinQ (#147008) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 111 ++++++++---------- .../lg_thinq/snapshots/test_climate.ambr | 4 +- 2 files changed, 52 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 98a86a8d355..4810336c6e0 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + PRESET_NONE, SWING_OFF, SWING_ON, ClimateEntity, @@ -22,7 +23,6 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.temperature import display_temp from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -109,11 +109,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_modes = [] + self._attr_preset_modes = [PRESET_NONE] + self._attr_preset_mode = PRESET_NONE self._attr_temperature_unit = ( self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS ) - self._requested_hvac_mode: str | None = None # Set up HVAC modes. for mode in self.data.hvac_modes: @@ -157,17 +157,19 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) if self.data.is_on: - hvac_mode = self._requested_hvac_mode or self.data.hvac_mode + hvac_mode = self.data.hvac_mode if hvac_mode in STR_TO_HVAC: self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode) - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE elif hvac_mode in THINQ_PRESET_MODE: + self._attr_hvac_mode = ( + HVACMode.COOL if hvac_mode == "energy_saving" else HVACMode.FAN_ONLY + ) self._attr_preset_mode = hvac_mode else: self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_mode = None + self._attr_preset_mode = PRESET_NONE - self.reset_requested_hvac_mode() self._attr_current_humidity = self.data.humidity self._attr_current_temperature = self.data.current_temp @@ -202,10 +204,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.target_temperature_step, ) - def reset_requested_hvac_mode(self) -> None: - """Cancel request to set hvac mode.""" - self._requested_hvac_mode = None - async def async_turn_on(self) -> None: """Turn the entity on.""" _LOGGER.debug( @@ -226,16 +224,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): await self.async_turn_off() return + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + # If device is off, turn on first. if not self.data.is_on: await self.async_turn_on() - # When we request hvac mode while turning on the device, the previously set - # hvac mode is displayed first and then switches to the requested hvac mode. - # To prevent this, set the requested hvac mode here so that it will be set - # immediately on the next update. - self._requested_hvac_mode = HVAC_TO_STR.get(hvac_mode) - _LOGGER.debug( "[%s:%s] async_set_hvac_mode: %s", self.coordinator.device_name, @@ -244,9 +239,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) await self.async_call_api( self.coordinator.api.async_set_hvac_mode( - self.property_id, self._requested_hvac_mode - ), - self.reset_requested_hvac_mode, + self.property_id, HVAC_TO_STR.get(hvac_mode) + ) ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -257,6 +251,8 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, preset_mode, ) + if preset_mode == PRESET_NONE: + preset_mode = "cool" if self.preset_mode == "energy_saving" else "fan" await self.async_call_api( self.coordinator.api.async_set_hvac_mode(self.property_id, preset_mode) ) @@ -301,59 +297,50 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): ) ) - def _round_by_step(self, temperature: float) -> float: - """Round the value by step.""" - if ( - target_temp := display_temp( - self.coordinator.hass, - temperature, - self.coordinator.hass.config.units.temperature_unit, - self.target_temperature_step or 1, - ) - ) is not None: - return target_temp - - return temperature - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + return + + if hvac_mode == HVACMode.HEAT_COOL: + hvac_mode = HVACMode.AUTO + + # If device is off, turn on first. + if not self.data.is_on: + await self.async_turn_on() + + if hvac_mode and hvac_mode != self.hvac_mode: + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + _LOGGER.debug( "[%s:%s] async_set_temperature: %s", self.coordinator.device_name, self.property_id, kwargs, ) - if hvac_mode := kwargs.get(ATTR_HVAC_MODE): - await self.async_set_hvac_mode(HVACMode(hvac_mode)) - if hvac_mode == HVACMode.OFF: - return - - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - if ( - target_temp := self._round_by_step(temperature) - ) != self.target_temperature: + if temperature := kwargs.get(ATTR_TEMPERATURE): + if self.data.step >= 1: + temperature = int(temperature) + if temperature != self.target_temperature: await self.async_call_api( self.coordinator.api.async_set_target_temperature( - self.property_id, target_temp + self.property_id, + temperature, ) ) - if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) is not None: - if ( - target_temp_low := self._round_by_step(temperature_low) - ) != self.target_temperature_low: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_low( - self.property_id, target_temp_low - ) - ) - - if (temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: - if ( - target_temp_high := self._round_by_step(temperature_high) - ) != self.target_temperature_high: - await self.async_call_api( - self.coordinator.api.async_set_target_temperature_high( - self.property_id, target_temp_high - ) + if (temperature_low := kwargs.get(ATTR_TARGET_TEMP_LOW)) and ( + temperature_high := kwargs.get(ATTR_TARGET_TEMP_HIGH) + ): + if self.data.step >= 1: + temperature_low = int(temperature_low) + temperature_high = int(temperature_high) + await self.async_call_api( + self.coordinator.api.async_set_target_temperature_low_high( + self.property_id, + temperature_low, + temperature_high, ) + ) diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index fd1b31e80bf..754969ff549 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ 'max_temp': 86, 'min_temp': 64, 'preset_modes': list([ + 'none', 'air_clean', ]), 'swing_horizontal_modes': list([ @@ -78,8 +79,9 @@ ]), 'max_temp': 86, 'min_temp': 64, - 'preset_mode': None, + 'preset_mode': 'none', 'preset_modes': list([ + 'none', 'air_clean', ]), 'supported_features': , From 56c53fdb9b9e5b34d4a7f39af0ee3572cbcb7147 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 24 Jul 2025 20:14:44 +0200 Subject: [PATCH 0602/1118] Allow Bluetooth proxy for Shelly devices only if Zigbee firmware is not active (#149193) Co-authored-by: Shay Levy Co-authored-by: Norbert Rittel --- homeassistant/components/shelly/__init__.py | 2 +- homeassistant/components/shelly/config_flow.py | 4 ++-- homeassistant/components/shelly/coordinator.py | 4 ++-- homeassistant/components/shelly/strings.json | 2 +- tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_config_flow.py | 8 ++++---- tests/components/shelly/test_coordinator.py | 6 +++--- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 0467b93a7c8..5582ab488df 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -298,7 +298,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_key="firmware_unsupported", translation_placeholders={"device": entry.title}, ) - runtime_data.rpc_zigbee_enabled = device.zigbee_enabled + runtime_data.rpc_zigbee_firmware = device.zigbee_firmware runtime_data.rpc_supports_scripts = await device.supports_scripts() if runtime_data.rpc_supports_scripts: runtime_data.rpc_script_events = await get_rpc_scripts_event_types( diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index bde57f6f9bc..d310f3525c5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -475,8 +475,8 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="cannot_connect") if not supports_scripts: return self.async_abort(reason="no_scripts_support") - if self.config_entry.runtime_data.rpc_zigbee_enabled: - return self.async_abort(reason="zigbee_enabled") + if self.config_entry.runtime_data.rpc_zigbee_firmware: + return self.async_abort(reason="zigbee_firmware") if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 9291d7aa70f..18430da8841 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -94,7 +94,7 @@ class ShellyEntryData: rpc_poll: ShellyRpcPollingCoordinator | None = None rpc_script_events: dict[int, list[str]] | None = None rpc_supports_scripts: bool | None = None - rpc_zigbee_enabled: bool | None = None + rpc_zigbee_firmware: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -730,7 +730,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if not self.sleep_period: if ( self.config_entry.runtime_data.rpc_supports_scripts - and not self.config_entry.runtime_data.rpc_zigbee_enabled + and not self.config_entry.runtime_data.rpc_zigbee_firmware ): await self._async_connect_ble_scanner() else: diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index c1d520a59f1..2bb5cd73bfd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -105,7 +105,7 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner.", - "zigbee_enabled": "Device with Zigbee enabled cannot be used as a Bluetooth scanner. Please disable it to use the device as a Bluetooth scanner." + "zigbee_firmware": "A device with Zigbee firmware cannot be used as a Bluetooth scanner. Please switch to Matter firmware to use the device as a Bluetooth scanner." } }, "selector": { diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 4eccb075b67..47ff723bddc 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -548,6 +548,7 @@ def _mock_rpc_device(version: str | None = None): ), xmod_info={}, zigbee_enabled=False, + zigbee_firmware=False, ip_address="10.10.10.10", ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 93893035a3e..3282756fe28 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -870,17 +870,17 @@ async def test_options_flow_abort_no_scripts_support( assert result["reason"] == "no_scripts_support" -async def test_options_flow_abort_zigbee_enabled( +async def test_options_flow_abort_zigbee_firmware( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: - """Test ble options abort if Zigbee is enabled for the device.""" - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", True) + """Test ble options abort if Zigbee firmware is active.""" + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", True) entry = await init_integration(hass, 4) result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "zigbee_enabled" + assert result["reason"] == "zigbee_firmware" async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 5b4372fe938..ff61eda626f 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -864,7 +864,7 @@ async def test_rpc_update_entry_fw_ver( @pytest.mark.parametrize( - ("supports_scripts", "zigbee_enabled", "result"), + ("supports_scripts", "zigbee_firmware", "result"), [ (True, False, True), (True, True, False), @@ -877,14 +877,14 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, supports_scripts: bool, - zigbee_enabled: bool, + zigbee_firmware: bool, result: bool, ) -> None: """Test RPC runs connected events when initialized.""" monkeypatch.setattr( mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) ) - monkeypatch.setattr(mock_rpc_device, "zigbee_enabled", zigbee_enabled) + monkeypatch.setattr(mock_rpc_device, "zigbee_firmware", zigbee_firmware) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) From 1d9f779b2a8b0112d93d6e7527d20549558e0668 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 21:03:36 +0200 Subject: [PATCH 0603/1118] Add missing hyphen to "case-sensitive" in `tuya` (#149400) --- homeassistant/components/tuya/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 954f5dbda8a..fd3a680ed3c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -2,13 +2,13 @@ "config": { "step": { "reauth_user_code": { - "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } }, "user": { - "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case sensitive, please be sure to enter it exactly as shown in the app.", + "description": "Enter your Smart Life or Tuya Smart user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app.", "data": { "user_code": "User code" } From b7b733efc3b55b313909ec0c6a03da79f1ad8d99 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 24 Jul 2025 21:03:45 +0200 Subject: [PATCH 0604/1118] Use common state for "Normal" in `switchbot` (#149399) --- homeassistant/components/switchbot/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 6077861e1c6..35482016e90 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -34,7 +34,7 @@ } }, "encrypted_auth": { - "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case sensitive.", + "description": "Please provide your SwitchBot app username and password. This data won't be saved and only used to retrieve your device's encryption key. Usernames and passwords are case-sensitive.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -206,7 +206,7 @@ }, "preset_mode": { "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "natural": "Natural", "sleep": "Sleep", "baby": "Baby" From 208dde10e64f45ae7a57927f9bcf1017db3cd646 Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Thu, 24 Jul 2025 21:08:47 +0200 Subject: [PATCH 0605/1118] Make default title configurable in XMPP (#149379) --- homeassistant/components/xmpp/notify.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 968f925d1e8..6ad0c1671a9 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -48,6 +48,7 @@ ATTR_URL = "url" ATTR_URL_TEMPLATE = "url_template" ATTR_VERIFY = "verify" +CONF_TITLE = "title" CONF_TLS = "tls" CONF_VERIFY = "verify" @@ -64,6 +65,7 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( vol.Optional(CONF_ROOM, default=""): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, + vol.Optional(CONF_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, } ) @@ -82,6 +84,7 @@ async def async_get_service( config.get(CONF_TLS), config.get(CONF_VERIFY), config.get(CONF_ROOM), + config.get(CONF_TITLE), hass, ) @@ -89,7 +92,9 @@ async def async_get_service( class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, resource, password, recipient, tls, verify, room, hass): + def __init__( + self, sender, resource, password, recipient, tls, verify, room, title, hass + ): """Initialize the service.""" self._hass = hass self._sender = sender @@ -99,10 +104,11 @@ class XmppNotificationService(BaseNotificationService): self._tls = tls self._verify = verify self._room = room + self._title = title async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + title = kwargs.get(ATTR_TITLE, self._title) text = f"{title}: {message}" if title else message data = kwargs.get(ATTR_DATA) timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None From fbe257f9976230ed4466aa565ae57a49654ef71b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:19:30 +0100 Subject: [PATCH 0606/1118] Add quality scale file to ring integration (#136454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/ring/quality_scale.yaml | 71 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ring/quality_scale.yaml diff --git a/homeassistant/components/ring/quality_scale.yaml b/homeassistant/components/ring/quality_scale.yaml new file mode 100644 index 00000000000..64bc5c23c3f --- /dev/null +++ b/homeassistant/components/ring/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: done + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: done + dependency-transparency: done + action-setup: + status: exempt + comment: The integration does not register services + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: The integration does not register custom service actions + brands: done + + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: todo + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: The integration does not have any options configuration parameters + + # Gold + entity-translations: + status: todo + comment: Use device class translations for volume sensor and number + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: todo + diagnostics: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + dynamic-devices: todo + discovery-update-info: + status: exempt + comment: The integration uses ring cloud api to identify devices and \ + does not use network identifiers + repair-issues: done + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done + + # 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 04812e9aefa..61c600c943f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -841,7 +841,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", From dbc2b1354b235d17dcc5ec25f4d015e8eac54c19 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 24 Jul 2025 22:11:47 +0200 Subject: [PATCH 0607/1118] UnifiProtect refactor sensor retrieval in tests to use get_sensor_by_key function (#149398) --- tests/components/unifiprotect/test_sensor.py | 63 ++++++++++++++------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 9489a49bf22..c65b3ac8e4e 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.components.unifiprotect.sensor import ( NVR_DISABLED_SENSORS, NVR_SENSORS, SENSE_SENSORS, + ProtectSensorEntityDescription, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -55,6 +56,16 @@ from .utils import ( from tests.common import async_capture_events + +def get_sensor_by_key(sensors: tuple, key: str) -> ProtectSensorEntityDescription: + """Get sensor description by key.""" + for sensor in sensors: + if sensor.key == key: + return sensor + raise ValueError(f"Sensor with key '{key}' not found") + + +# Constants for test slicing (subsets of sensor tuples) CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] @@ -123,7 +134,9 @@ async def test_sensor_setup_sensor( # BLE signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, ALL_DEVICES_SENSORS[1] + Platform.SENSOR, + sensor_all, + get_sensor_by_key(ALL_DEVICES_SENSORS, "ble_signal"), ) entity = entity_registry.async_get(entity_id) @@ -269,7 +282,7 @@ async def test_sensor_nvr_missing_values( assert_entity_counts(hass, Platform.SENSOR, 12, 9) # Uptime - description = NVR_SENSORS[0] + description = get_sensor_by_key(NVR_SENSORS, "uptime") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -285,8 +298,8 @@ async def test_sensor_nvr_missing_values( assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_SENSORS[8] + # Recording capacity + description = get_sensor_by_key(NVR_SENSORS, "record_capacity") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -300,8 +313,8 @@ async def test_sensor_nvr_missing_values( assert state.state == "0" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Memory - description = NVR_DISABLED_SENSORS[2] + # Memory utilization + description = get_sensor_by_key(NVR_DISABLED_SENSORS, "memory_utilization") unique_id, entity_id = ids_from_device_description( Platform.SENSOR, nvr, description ) @@ -372,9 +385,9 @@ async def test_sensor_setup_camera( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Wired signal + # Wired signal (phy_rate / link speed) unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[2] + Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate") ) entity = entity_registry.async_get(entity_id) @@ -391,7 +404,9 @@ async def test_sensor_setup_camera( # WiFi signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[3] + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "wifi_signal"), ) entity = entity_registry.async_get(entity_id) @@ -422,7 +437,9 @@ async def test_sensor_setup_camera_with_last_trip_time( # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] + Platform.SENSOR, + doorbell, + get_sensor_by_key(MOTION_TRIP_SENSORS, "motion_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -447,7 +464,7 @@ async def test_sensor_update_alarm( assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[4] + Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "alarm_sound") ) event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") @@ -498,7 +515,9 @@ async def test_sensor_update_alarm_with_last_trip_time( # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "door_last_trip_time"), ) entity = entity_registry.async_get(entity_id) @@ -529,7 +548,9 @@ async def test_camera_update_license_plate( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -644,7 +665,9 @@ async def test_camera_update_license_plate_changes_number_during_detect( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -731,7 +754,9 @@ async def test_camera_update_license_plate_multiple_updates( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -854,7 +879,9 @@ async def test_camera_update_license_no_dupes( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + Platform.SENSOR, + camera, + get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), ) event_metadata = EventMetadata( @@ -946,6 +973,8 @@ async def test_sensor_precision( assert_entity_counts(hass, Platform.SENSOR, 22, 14) nvr: NVR = ufp.api.bootstrap.nvr - _, entity_id = ids_from_device_description(Platform.SENSOR, nvr, NVR_SENSORS[6]) + _, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") + ) assert hass.states.get(entity_id).state == "17.49" From 4cc4bd3b9a5999a804dcd878148dcf8aadf85bf1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:28:56 -0400 Subject: [PATCH 0608/1118] Remove redundant async_set_context from platforms (#149403) --- homeassistant/components/template/binary_sensor.py | 1 - homeassistant/components/template/cover.py | 1 - homeassistant/components/template/fan.py | 1 - homeassistant/components/template/light.py | 1 - homeassistant/components/template/lock.py | 1 - homeassistant/components/template/switch.py | 1 - homeassistant/components/template/vacuum.py | 1 - 7 files changed, 7 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 567e9e3a110..a2c5c7d460a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -370,7 +370,6 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity def _set_state(self, state, _=None): """Set up auto off.""" self._attr_is_on = state - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() if not state: diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 8f88baea091..e8739fa8207 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -492,7 +492,6 @@ class TriggerCoverEntity(TriggerEntity, AbstractTemplateCover): write_ha_state = True if not self._attr_assumed_state: - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 13d2414aea2..2d0d06f86a1 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -561,5 +561,4 @@ class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 802fc145427..07591ce9653 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1166,7 +1166,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): raw = self._rendered.get(CONF_STATE) self._state = template.result_as_boolean(raw) - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._optimistic and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index a2f1f56bea2..848469b0ca4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -372,7 +372,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): write_ha_state = True if not self._optimistic: - self.async_set_context(self.coordinator.data["context"]) write_ha_state = True elif self._optimistic and len(self._rendered) > 0: # In case any non optimistic template diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index b1d72084ae7..bd271e4b17c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -295,7 +295,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): raw = self._rendered.get(CONF_STATE) self._attr_is_on = template.result_as_boolean(raw) - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() elif self._attr_assumed_state and len(self._rendered) > 0: # In case name, icon, or friendly name have a template but diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 0056eca9b99..5ff99020f0d 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -404,5 +404,4 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): write_ha_state = True if write_ha_state: - self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() From 3ba144c8b249fa1a4112633b1b931e6ad0481d39 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Fri, 25 Jul 2025 00:38:48 +0100 Subject: [PATCH 0609/1118] Bump monzopy to 1.5.1 (#149410) --- homeassistant/components/monzo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index 7038cecd7ea..dc9a11be3ac 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.4.2"] + "requirements": ["monzopy==1.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c0bb3df3a4..043e75ad64e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1449,7 +1449,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b028a880bfd..fc0b2640043 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1241,7 +1241,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo -monzopy==1.4.2 +monzopy==1.5.1 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 From 59ece455d95e2add3fa1dfb1fce0d4f4e8b414fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 02:24:25 +0200 Subject: [PATCH 0610/1118] Update numpy to 2.3.2 (#149411) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index eae58caa255..4de2a39ec32 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", "quality_scale": "legacy", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 75253099cdb..48a89f5a96a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.3.0", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6eaee7f1534..8ba8904751e 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.0"] + "requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.3.2"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 15d96469ee4..1144fd7a4af 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,7 +10,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.3.0", + "numpy==2.3.2", "Pillow==11.3.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index e35c10a9ece..a6d0f8a0427 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.3.0"] + "requirements": ["numpy==2.3.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f0e0408efd..8316bed251d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -118,7 +118,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index 043e75ad64e..e284bf2adb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games nyt_games==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc0b2640043..b0cd9d3a77c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.3.0 +numpy==2.3.2 # homeassistant.components.nyt_games nyt_games==0.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b45d48aeff4..13bb3384258 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -144,7 +144,7 @@ httpcore==1.0.9 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.3.0 +numpy==2.3.2 pandas==2.3.0 # Constrain multidict to avoid typing issues From 7e9da052cae8077b4969e5433dfa3799b430ca18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 25 Jul 2025 08:17:26 +0200 Subject: [PATCH 0611/1118] Update aioairzone-cloud to v0.7.1 (#149388) 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_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/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0747678c5a4..8f89ec88271 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.7.0"] + "requirements": ["aioairzone-cloud==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e284bf2adb3..dbb814bfcde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.0 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0cd9d3a77c..6753f4a9fce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.6 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.7.0 +aioairzone-cloud==0.7.1 # homeassistant.components.airzone aioairzone==1.0.0 From 95d4dc678cf79cf91cc7e38061532af7b3132e77 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 25 Jul 2025 12:14:36 +0200 Subject: [PATCH 0612/1118] Add option traffic_mode in here_travel_time (#146676) --- .../components/here_travel_time/__init__.py | 31 ++++++++++- .../here_travel_time/config_flow.py | 17 +++++- .../components/here_travel_time/const.py | 1 + .../here_travel_time/coordinator.py | 12 ++++- .../components/here_travel_time/model.py | 3 +- .../components/here_travel_time/strings.json | 5 +- .../here_travel_time/test_config_flow.py | 24 ++++++++- .../components/here_travel_time/test_init.py | 32 +++++++++++- .../here_travel_time/test_sensor.py | 52 +++++++++++++++++-- 9 files changed, 166 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 5393dfa5050..741a9a1058c 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations +import logging + from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .const import TRAVEL_MODE_PUBLIC +from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, @@ -15,6 +17,8 @@ from .coordinator import ( PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool: """Set up HERE Travel Time from a config entry.""" @@ -43,3 +47,28 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: HereConfigEntry +) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1 and config_entry.minor_version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + options[CONF_TRAFFIC_MODE] = True + + hass.config_entries.async_update_entry( + config_entry, options=options, version=1, minor_version=2 + ) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 6425b5ffbed..5ff0a68bc9a 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, EntitySelector, LocationSelector, TimeSelector, @@ -50,6 +51,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_NAME, DOMAIN, ROUTE_MODE_FASTEST, @@ -65,6 +67,7 @@ DEFAULT_OPTIONS = { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -102,6 +105,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for HERE Travel Time.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Init Config Flow.""" @@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow): """Manage the HERE Travel Time options.""" if user_input is not None: self._config = user_input - return await self.async_step_time_menu() + if self._config[CONF_TRAFFIC_MODE]: + return await self.async_step_time_menu() + return self.async_create_entry(title="", data=self._config) schema = self.add_suggested_values_to_schema( vol.Schema( @@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow): CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), ): vol.In(ROUTE_MODES), + vol.Optional( + CONF_TRAFFIC_MODE, + default=self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), + ): BooleanSelector(), } ), { CONF_ROUTE_MODE: self.config_entry.options.get( CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE] ), + CONF_TRAFFIC_MODE: self.config_entry.options.get( + CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE] + ), }, ) diff --git a/homeassistant/components/here_travel_time/const.py b/homeassistant/components/here_travel_time/const.py index 785070cd3b1..cc208d95abe 100644 --- a/homeassistant/components/here_travel_time/const.py +++ b/homeassistant/components/here_travel_time/const.py @@ -19,6 +19,7 @@ CONF_ARRIVAL = "arrival" CONF_DEPARTURE = "departure" CONF_ARRIVAL_TIME = "arrival_time" CONF_DEPARTURE_TIME = "departure_time" +CONF_TRAFFIC_MODE = "traffic_mode" DEFAULT_NAME = "HERE Travel Time" diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index d8c698554c9..0e447770ca9 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -13,6 +13,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) import here_transit @@ -44,6 +45,7 @@ from .const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST, @@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," - " mode: %s, arrival: %s, departure: %s" + " mode: %s, arrival: %s, departure: %s, traffic_mode: %s" ), params.origin, params.destination, @@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] TransportMode(params.travel_mode), params.arrival, params.departure, + params.traffic_mode, ) try: @@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] routing_mode=params.route_mode, arrival_time=params.arrival, departure_time=params.departure, + traffic_mode=params.traffic_mode, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -350,6 +354,11 @@ def prepare_parameters( if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST else RoutingMode.SHORT ) + traffic_mode = ( + TrafficMode.DISABLED + if config_entry.options[CONF_TRAFFIC_MODE] is False + else TrafficMode.DEFAULT + ) return HERETravelTimeAPIParams( destination=destination, @@ -358,6 +367,7 @@ def prepare_parameters( route_mode=route_mode, arrival=arrival, departure=departure, + traffic_mode=traffic_mode, ) diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index a0534d2ff01..deb886f6805 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from typing import TypedDict -from here_routing import RoutingMode +from here_routing import RoutingMode, TrafficMode class HERETravelTimeData(TypedDict): @@ -32,3 +32,4 @@ class HERETravelTimeAPIParams: route_mode: RoutingMode arrival: datetime | None departure: datetime | None + traffic_mode: TrafficMode diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 89350261299..639be3326f9 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -60,8 +60,11 @@ "step": { "init": { "data": { - "traffic_mode": "Traffic mode", + "traffic_mode": "Use traffic and time-aware routing", "route_mode": "Route mode" + }, + "data_description": { + "traffic_mode": "Needed for defining arrival/departure times" } }, "time_menu": { diff --git a/tests/components/here_travel_time/test_config_flow.py b/tests/components/here_travel_time/test_config_flow.py index ce210813fb2..82c75471896 100644 --- a/tests/components/here_travel_time/test_config_flow.py +++ b/tests/components/here_travel_time/test_config_flow.py @@ -6,7 +6,10 @@ from here_routing import HERERoutingError, HERERoutingUnauthorizedError import pytest from homeassistant import config_entries -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -17,6 +20,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, @@ -86,6 +90,8 @@ async def option_init_result_fixture( CONF_MODE: TRAVEL_MODE_PUBLIC, CONF_NAME: "test", }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -249,6 +255,7 @@ async def test_step_destination_entity( CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: None, CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, } @@ -317,6 +324,8 @@ async def do_common_reconfiguration_steps(hass: HomeAssistant) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -398,6 +407,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="0123456789", data=DEFAULT_CONFIG, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) @@ -414,10 +425,16 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, }, ) - assert result["type"] is FlowResultType.MENU + assert result["type"] is FlowResultType.CREATE_ENTRY + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.options == { + CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: False, + } @pytest.mark.usefixtures("valid_response") @@ -441,6 +458,7 @@ async def test_options_flow_arrival_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -465,6 +483,7 @@ async def test_options_flow_departure_time_step( assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_DEPARTURE_TIME: "08:00:00", + CONF_TRAFFIC_MODE: True, } @@ -481,4 +500,5 @@ async def test_options_flow_no_time_step( entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.options == { CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: True, } diff --git a/tests/components/here_travel_time/test_init.py b/tests/components/here_travel_time/test_init.py index ff09c7e6ae9..4dbddd46633 100644 --- a/tests/components/here_travel_time/test_init.py +++ b/tests/components/here_travel_time/test_init.py @@ -4,14 +4,19 @@ from datetime import datetime import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DOMAIN, ROUTE_MODE_FASTEST, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import DEFAULT_CONFIG @@ -44,9 +49,34 @@ async def test_unload_entry(hass: HomeAssistant, options) -> None: unique_id="0123456789", data=DEFAULT_CONFIG, options=options, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.usefixtures("valid_response") +async def test_migrate_entry_v1_1_v1_2( + hass: HomeAssistant, +) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=1, + minor_version=1, + ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.minor_version == 2 + assert updated_entry.options[CONF_TRAFFIC_MODE] is True diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 7c8946b7049..b96e77a6b6d 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -11,6 +11,7 @@ from here_routing import ( Return, RoutingMode, Spans, + TrafficMode, TransportMode, ) from here_transit import ( @@ -21,7 +22,10 @@ from here_transit import ( ) import pytest -from homeassistant.components.here_travel_time.config_flow import DEFAULT_OPTIONS +from homeassistant.components.here_travel_time.config_flow import ( + DEFAULT_OPTIONS, + HERETravelTimeConfigFlow, +) from homeassistant.components.here_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, @@ -32,6 +36,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + CONF_TRAFFIC_MODE, DEFAULT_SCAN_INTERVAL, DOMAIN, ICON_BICYCLE, @@ -85,29 +90,33 @@ from tests.common import ( @pytest.mark.parametrize( - ("mode", "icon", "arrival_time", "departure_time"), + ("mode", "icon", "traffic_mode", "arrival_time", "departure_time"), [ ( TRAVEL_MODE_CAR, ICON_CAR, + False, None, None, ), ( TRAVEL_MODE_BICYCLE, ICON_BICYCLE, + True, None, None, ), ( TRAVEL_MODE_PEDESTRIAN, ICON_PEDESTRIAN, + True, None, "08:00:00", ), ( TRAVEL_MODE_TRUCK, ICON_TRUCK, + True, None, "08:00:00", ), @@ -118,6 +127,7 @@ async def test_sensor( hass: HomeAssistant, mode, icon, + traffic_mode, arrival_time, departure_time, ) -> None: @@ -137,9 +147,12 @@ async def test_sensor( }, options={ CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, + CONF_TRAFFIC_MODE: traffic_mode, CONF_ARRIVAL_TIME: arrival_time, CONF_DEPARTURE_TIME: departure_time, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -197,6 +210,8 @@ async def test_circular_ref( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -228,7 +243,10 @@ async def test_public_transport(hass: HomeAssistant) -> None: CONF_ROUTE_MODE: ROUTE_MODE_FASTEST, CONF_ARRIVAL_TIME: "08:00:00", CONF_DEPARTURE_TIME: None, + CONF_TRAFFIC_MODE: True, }, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -260,6 +278,8 @@ async def test_no_attribution_response(hass: HomeAssistant) -> None: CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -307,6 +327,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -324,6 +346,7 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non routing_mode=RoutingMode.FAST, arrival_time=None, departure_time=None, + traffic_mode=TrafficMode.DEFAULT, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -346,6 +369,8 @@ async def test_destination_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -374,6 +399,8 @@ async def test_origin_entity_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -406,6 +433,8 @@ async def test_invalid_destination_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -440,6 +469,8 @@ async def test_invalid_origin_entity_state( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -476,6 +507,8 @@ async def test_route_not_found( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -587,7 +620,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # create and add entry mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id=DOMAIN, data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS + domain=DOMAIN, + unique_id=DOMAIN, + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) mock_entry.add_to_hass(hass) @@ -656,6 +694,8 @@ async def test_transit_errors( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -682,6 +722,8 @@ async def test_routing_rate_limit( unique_id="0123456789", data=DEFAULT_CONFIG, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -739,6 +781,8 @@ async def test_transit_rate_limit( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -791,6 +835,8 @@ async def test_multiple_sections( CONF_NAME: "test", }, options=DEFAULT_OPTIONS, + version=HERETravelTimeConfigFlow.VERSION, + minor_version=HERETravelTimeConfigFlow.MINOR_VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From b7da31a0212f9399de20a14f1d7018d7b7f13950 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:15:42 +0300 Subject: [PATCH 0613/1118] Bump pyosoenergyapi to 1.2.3 (#149422) --- homeassistant/components/osoenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index 6129aa379f7..5f0e1b93027 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.5"] + "requirements": ["pyosoenergyapi==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbb814bfcde..45bac7969ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2215,7 +2215,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.3 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6753f4a9fce..ab46679547b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1842,7 +1842,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.5 +pyosoenergyapi==1.2.3 # homeassistant.components.opentherm_gw pyotgw==2.2.2 From f7cc260336e6dd3f98d18f89a7face3a7ba1b478 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 25 Jul 2025 12:20:33 +0200 Subject: [PATCH 0614/1118] Add quality scale for devolo Home Network (#131510) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> --- .../devolo_home_network/manifest.json | 1 + .../devolo_home_network/quality_scale.yaml | 84 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/devolo_home_network/quality_scale.yaml diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 31f3a51ebeb..37fb2682883 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,6 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["devolo_plc_api"], + "quality_scale": "silver", "requirements": ["devolo-plc-api==1.5.1"], "zeroconf": [ { diff --git a/homeassistant/components/devolo_home_network/quality_scale.yaml b/homeassistant/components/devolo_home_network/quality_scale.yaml new file mode 100644 index 00000000000..dda228c47e3 --- /dev/null +++ b/homeassistant/components/devolo_home_network/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + A change of the IP address is covered by discovery-update-info and a change of the password is covered by reauthentication-flow. No other configuration options are available. + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: todo + comment: | + The tracked devices could be own devices with a manual delete option as the API cannot distinguish between stale devices and devices that are not home. + + # 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 61c600c943f..b42e1e415aa 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -285,7 +285,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", @@ -1320,7 +1319,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "devialet", "device_sun_light_trigger", "devolo_home_control", - "devolo_home_network", "dexcom", "dhcp", "dialogflow", From 6920dec352f938d34b4f825ec35f0fc99d2f8928 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 25 Jul 2025 12:55:42 +0200 Subject: [PATCH 0615/1118] Rework devolo Home Control config flow (#147121) --- .../devolo_home_control/config_flow.py | 109 ++++++++---------- 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index c4f57b2398a..64220949270 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -7,45 +7,39 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlow, - ConfigFlowResult, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import configure_mydevolo from .const import DOMAIN, SUPPORTED_MODEL_TYPES from .exceptions import CredentialsInvalid, UuidChanged +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a devolo HomeControl config flow.""" VERSION = 1 - _reauth_entry: ConfigEntry - - def __init__(self) -> None: - """Initialize devolo Home Control flow.""" - self.data_schema = { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - if user_input is None: - return self._show_form(step_id="user") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form(step_id="user", errors={"base": "invalid_auth"}) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -61,42 +55,47 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by zeroconf.""" - if user_input is None: - return self._show_form(step_id="zeroconf_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="zeroconf_confirm", errors={"base": "invalid_auth"} - ) + errors: dict[str, str] = {} + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauthentication.""" - self._reauth_entry = self._get_reauth_entry() - self.data_schema = { - vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD): str, - } return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by reauthentication.""" - if user_input is None: - return self._show_form(step_id="reauth_confirm") - try: - return await self._connect_mydevolo(user_input) - except CredentialsInvalid: - return self._show_form( - step_id="reauth_confirm", errors={"base": "invalid_auth"} - ) - except UuidChanged: - return self._show_form( - step_id="reauth_confirm", errors={"base": "reauth_failed"} - ) + errors: dict[str, str] = {} + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + if user_input is not None: + try: + return await self._connect_mydevolo(user_input) + except CredentialsInvalid: + errors["base"] = "invalid_auth" + except UuidChanged: + errors["base"] = "reauth_failed" + + return self.async_show_form( + step_id="reauth_confirm", data_schema=data_schema, errors=errors + ) async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Connect to mydevolo.""" @@ -119,21 +118,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - if self._reauth_entry.unique_id != uuid: + if self.unique_id != uuid: # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. raise UuidChanged + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( - self._reauth_entry, data=user_input, unique_id=uuid - ) - - @callback - def _show_form( - self, step_id: str, errors: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Show the form to the user.""" - return self.async_show_form( - step_id=step_id, - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, + reauth_entry, data=user_input, unique_id=uuid ) From 123cce6d96a30d7edcdf81fca0a068da1285b43f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 25 Jul 2025 14:26:32 +0300 Subject: [PATCH 0616/1118] Add configuration URL and model details to Shelly sub device info (#149404) --- homeassistant/components/shelly/button.py | 6 ++++++ homeassistant/components/shelly/climate.py | 3 +++ homeassistant/components/shelly/coordinator.py | 14 ++++++++++++-- homeassistant/components/shelly/entity.py | 15 +++++++++++++++ homeassistant/components/shelly/event.py | 3 +++ homeassistant/components/shelly/sensor.py | 3 +++ homeassistant/components/shelly/utils.py | 15 +++++++++++++++ 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index ad03a373dba..2ab23441c98 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -237,12 +237,18 @@ class ShellyButton(ShellyBaseButton): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index abc387f3efd..2a09e867dce 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -213,6 +213,9 @@ class BlockSleepingClimate( self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, sensor_block, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 18430da8841..eba6b846fe4 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -145,11 +145,21 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @cached_property + def configuration_url(self) -> str: + """Return the configuration URL for the device.""" + return f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}" + @cached_property def model(self) -> str: """Model of the device.""" return cast(str, self.config_entry.data[CONF_MODEL]) + @cached_property + def model_name(self) -> str | None: + """Model name of the device.""" + return get_shelly_model_name(self.model, self.sleep_period, self.device) + @cached_property def mac(self) -> str: """Mac address of the device.""" @@ -175,11 +185,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=get_shelly_model_name(self.model, self.sleep_period, self.device), + model=self.model_name, model_id=self.model, sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.config_entry)}", - configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", + configuration_url=self.configuration_url, ) # We want to use the main device area as the suggested area for sub-devices. if (area_id := device_entry.area_id) is not None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index b80ac877a84..33a45a0e10f 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -371,6 +371,9 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, block, suggested_area=coordinator.suggested_area, ) @@ -417,6 +420,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) @@ -536,6 +542,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -647,6 +656,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self._attr_device_info = get_block_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, block, suggested_area=coordinator.suggested_area, ) @@ -717,6 +729,9 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 2eb9ff00964..9e1c748c790 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -209,6 +209,9 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cefcbb86a98..cdfa97357f2 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -141,6 +141,9 @@ class RpcEmeterPhaseSensor(RpcSensor): self._attr_device_info = get_rpc_device_info( coordinator.device, coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, key, emeter_phase=description.emeter_phase, suggested_area=coordinator.suggested_area, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 1af365debfb..2ee960348dd 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -749,6 +749,9 @@ async def get_rpc_scripts_event_types( def get_rpc_device_info( device: RpcDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, key: str | None = None, emeter_phase: str | None = None, suggested_area: str | None = None, @@ -771,8 +774,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, name=get_rpc_sub_device_name(device, key, emeter_phase), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) if ( @@ -786,8 +792,11 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) @@ -810,6 +819,9 @@ def get_blu_trv_device_info( def get_block_device_info( device: BlockDevice, mac: str, + configuration_url: str, + model: str, + model_name: str | None = None, block: Block | None = None, suggested_area: str | None = None, ) -> DeviceInfo: @@ -826,8 +838,11 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + model=model_name, + model_id=model, suggested_area=suggested_area, via_device=(DOMAIN, mac), + configuration_url=configuration_url, ) From e3ffb41650b6c080dff1ba0162c077ea41046662 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 13:52:01 +0200 Subject: [PATCH 0617/1118] Improve some option and state names in `home_connect` (#149373) --- .../components/home_connect/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 853d2bd2f8e..0b094a9d49a 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -193,11 +193,11 @@ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner Brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser Brauner", "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener Melange", "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", @@ -279,7 +279,7 @@ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", "cooking_oven_program_heating_mode_keep_warm": "Keep warm", "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products", "cooking_oven_program_heating_mode_desiccation": "Desiccation", "cooking_oven_program_heating_mode_defrost": "Defrost", "cooking_oven_program_heating_mode_proof": "Proof", @@ -316,8 +316,8 @@ "laundry_care_washer_program_monsoon": "Monsoon", "laundry_care_washer_program_outdoor": "Outdoor", "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_shirts_blouses": "Shirts/blouses", + "laundry_care_washer_program_sport_fitness": "Sport/fitness", "laundry_care_washer_program_towels": "Towels", "laundry_care_washer_program_water_proof": "Water proof", "laundry_care_washer_program_power_speed_59": "Power speed <59 min", @@ -1291,9 +1291,9 @@ "state": { "cooking_hood_enum_type_color_temperature_custom": "Custom", "cooking_hood_enum_type_color_temperature_warm": "Warm", - "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to neutral", "cooking_hood_enum_type_color_temperature_neutral": "Neutral", - "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to cold", "cooking_hood_enum_type_color_temperature_cold": "Cold" } }, From c1fa721a57525f9c87343257725ecce5705df480 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 25 Jul 2025 14:03:44 +0100 Subject: [PATCH 0618/1118] Revert "Use OptionsFlowWithReload in mqtt" (#149431) --- homeassistant/components/mqtt/__init__.py | 11 +++++++++++ homeassistant/components/mqtt/config_flow.py | 6 +++--- tests/components/mqtt/test_config_flow.py | 16 +++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 4f00c4da958..9e3dc59f852 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -246,6 +246,14 @@ MQTT_PUBLISH_SCHEMA = vol.Schema( ) +async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle signals of config entry being updated. + + Causes for this is config entry options changing. + """ + await hass.config_entries.async_reload(entry.entry_id) + + @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" @@ -427,6 +435,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data.subscriptions_to_restore ) mqtt_data.subscriptions_to_restore = set() + mqtt_data.reload_dispatchers.append( + entry.add_update_listener(_async_config_entry_updated) + ) return (mqtt_data, conf) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 023872d410c..52f00c82c27 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -52,7 +52,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlowWithReload, + OptionsFlow, SubentryFlowResult, ) from homeassistant.const import ( @@ -2537,7 +2537,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class MQTTOptionsFlowHandler(OptionsFlowWithReload): +class MQTTOptionsFlowHandler(OptionsFlow): """Handle MQTT options.""" async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: @@ -3353,7 +3353,7 @@ def _validate_pki_file( async def async_get_broker_settings( # noqa: C901 - flow: ConfigFlow | OptionsFlowWithReload, + flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, user_input: dict[str, Any] | None, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index b45a4a66aa9..ce0a0c44a79 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -17,10 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError -from homeassistant.components.mqtt.config_flow import ( - PWD_NOT_CHANGED, - MQTTOptionsFlowHandler, -) +from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( @@ -196,8 +193,8 @@ def mock_ssl_context(mock_context_client_key: bytes) -> Generator[dict[str, Magi @pytest.fixture def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" - with patch.object( - MQTTOptionsFlowHandler, "automatic_reload", return_value=False + with patch( + "homeassistant.components.mqtt._async_config_entry_updated" ) as mock_reload: yield mock_reload @@ -1333,11 +1330,11 @@ async def test_keepalive_validation( assert result["reason"] == "reconfigure_successful" -@pytest.mark.usefixtures("mock_reload_after_entry_update") async def test_disable_birth_will( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mock_try_connection: MagicMock, + mock_reload_after_entry_update: MagicMock, ) -> None: """Test disabling birth and will.""" await mqtt_mock_entry() @@ -1351,6 +1348,7 @@ async def test_disable_birth_will( }, ) await hass.async_block_till_done() + mock_reload_after_entry_update.reset_mock() result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1389,6 +1387,10 @@ async def test_disable_birth_will( mqtt.CONF_WILL_MESSAGE: {}, } + await hass.async_block_till_done() + # assert that the entry was reloaded with the new config + assert mock_reload_after_entry_update.call_count == 1 + async def test_invalid_discovery_prefix( hass: HomeAssistant, From 4bbb94f43deb199ae9762f9ed3ce97197340634e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:05:20 +0200 Subject: [PATCH 0619/1118] Update coverage to 7.10.0 (#149412) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cc9eff9dc3f..6c0fc02df58 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.10 -coverage==7.9.1 +coverage==7.10.0 freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 From f3513f7f29c0df256eac2d1a6044ffebf28dd42f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 19:01:57 +0200 Subject: [PATCH 0620/1118] Add missing hyphen to "case-sensitive" in `tplink` (#149363) --- homeassistant/components/tplink/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index a7f9dfbcb09..70eff4a34c4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -30,8 +30,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "Your TP-Link cloud username which is the full email and is case sensitive.", - "password": "Your TP-Link cloud password which is case sensitive." + "username": "Your TP-Link cloud username which is the full email and is case-sensitive.", + "password": "Your TP-Link cloud password which is case-sensitive." } }, "discovery_auth_confirm": { From 356ac74fa507e96169fb473105bdab69c6cf041b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:07:07 +0200 Subject: [PATCH 0621/1118] Update orjson to 3.11.1 (#149442) --- 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 8316bed251d..88aa9418ddc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.11.0 +orjson==3.11.1 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index b1b43c80cd2..162f63ff064 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", - "orjson==3.11.0", + "orjson==3.11.1", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index e4065bed83e..65d0309747e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==45.0.3 Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 -orjson==3.11.0 +orjson==3.11.1 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 65109ea000b5e09417bd8e9c7a2e751db6004a18 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Fri, 25 Jul 2025 13:09:58 -0400 Subject: [PATCH 0622/1118] Fix Matter light get brightness (#149186) --- homeassistant/components/matter/light.py | 7 ++++++- tests/components/matter/test_light.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c61fd0879fa..a86938730c9 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models import device_types from homeassistant.components.light import ( @@ -241,7 +242,7 @@ class MatterLight(MatterEntity, LightEntity): return int(color_temp) - def _get_brightness(self) -> int: + def _get_brightness(self) -> int | None: """Get brightness from matter.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -255,6 +256,10 @@ class MatterLight(MatterEntity, LightEntity): self.entity_id, ) + if level_control.currentLevel is NullValue: + # currentLevel is a nullable value. + return None + return round( renormalize( level_control.currentLevel, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index b600ededa6e..f9abf986170 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -131,6 +131,15 @@ async def test_dimmable_light( ) -> None: """Test a dimmable light.""" + # Test for currentLevel is None + set_node_attribute(matter_node, 1, 8, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] is None + # Test that the light brightness is 50 (out of 254) set_node_attribute(matter_node, 1, 8, 0, 50) await trigger_subscription_callback(hass, matter_client) From aad1dbecb4e1cc287e1fac7971ca13a46c6ec6a6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 19:28:43 +0200 Subject: [PATCH 0623/1118] Fix spelling of "IP" and improve action descriptions in `lcn` (#149314) --- homeassistant/components/lcn/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 4e4ca7e0dcd..90d4bdcd4ad 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -70,7 +70,7 @@ }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "already_configured": "PCHK connection using the same ip address/port is already configured." + "already_configured": "PCHK connection using the same IP address/port is already configured." } }, "issues": { @@ -156,7 +156,7 @@ }, "relays": { "name": "Relays", - "description": "Sets the relays status.", + "description": "Sets the relay states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", @@ -168,7 +168,7 @@ }, "state": { "name": "State", - "description": "Relays states as string (1=on, 2=off, t=toggle, -=no change)." + "description": "Relay states as string (1=on, 2=off, t=toggle, -=no change)." } } }, @@ -322,7 +322,7 @@ }, "lock_keys": { "name": "Lock keys", - "description": "Locks keys.", + "description": "Sets the key lock states.", "fields": { "device_id": { "name": "[%key:common::config_flow::data::device%]", From b3130c7929479e7122f8f7d463ccff5ca4f0b700 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:29:40 +0200 Subject: [PATCH 0624/1118] Bump aioautomower to 2.0.2 (#149441) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0234ac58e39..798bd631e43 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.1"] + "requirements": ["aioautomower==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45bac7969ab..9c9c6c7d20f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.1 +aioautomower==2.0.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab46679547b..0d5a0efa684 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.1 +aioautomower==2.0.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 02eb1dd533b98ceca9978216dac9273b3dadedf3 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Fri, 25 Jul 2025 20:30:58 +0300 Subject: [PATCH 0625/1118] Bump pyosoenergyapi to 1.2.4 (#149439) --- homeassistant/components/osoenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index 5f0e1b93027..b47fb0fe08a 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.2.3"] + "requirements": ["pyosoenergyapi==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c9c6c7d20f..f308f39aa86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2215,7 +2215,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.2.3 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d5a0efa684..daac6214663 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1842,7 +1842,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.2.3 +pyosoenergyapi==1.2.4 # homeassistant.components.opentherm_gw pyotgw==2.2.2 From a069b59efc50e7ed0f3a3b57c2edc67a98b92d82 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:55:40 -0400 Subject: [PATCH 0626/1118] Transition template types from string to platform keys (#149434) --- homeassistant/components/template/config_flow.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index bb5ee14c7d2..7e06ef51a4b 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -324,14 +324,14 @@ def validate_user_input( TEMPLATE_TYPES = [ - "alarm_control_panel", - "binary_sensor", - "button", - "image", - "number", - "select", - "sensor", - "switch", + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.IMAGE, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, ] CONFIG_FLOW = { From b2710c1bce76bbf84a3e78444c182b199f4833a7 Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Fri, 25 Jul 2025 11:10:39 -0700 Subject: [PATCH 0627/1118] Add smarttub cover sensor (#139134) Co-authored-by: Erik Montnemery --- .../components/smarttub/binary_sensor.py | 32 +++++++++++++++-- homeassistant/components/smarttub/const.py | 1 + .../components/smarttub/controller.py | 2 ++ homeassistant/components/smarttub/entity.py | 34 ++++++++++++++++--- homeassistant/components/smarttub/sensor.py | 20 +++++------ tests/components/smarttub/conftest.py | 11 ++++++ .../components/smarttub/test_binary_sensor.py | 11 ++++++ 7 files changed, 94 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a120650e84b..1a329ce8a25 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from smarttub import Spa, SpaError, SpaReminder @@ -17,9 +18,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTR_ERRORS, ATTR_REMINDERS +from .const import ATTR_ERRORS, ATTR_REMINDERS, ATTR_SENSORS from .controller import SmartTubConfigEntry -from .entity import SmartTubEntity, SmartTubSensorBase +from .entity import ( + SmartTubEntity, + SmartTubExternalSensorBase, + SmartTubOnboardSensorBase, +) # whether the reminder has been snoozed (bool) ATTR_REMINDER_SNOOZED = "snoozed" @@ -44,6 +49,8 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { ) } +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -62,6 +69,12 @@ async def async_setup_entry( SmartTubReminder(controller.coordinator, spa, reminder) for reminder in controller.coordinator.data[spa.id][ATTR_REMINDERS].values() ) + for sensor in controller.coordinator.data[spa.id][ATTR_SENSORS].values(): + name = sensor.name.strip("{}") + if name.startswith("cover-"): + entities.append( + SmartTubCoverSensor(controller.coordinator, spa, sensor) + ) async_add_entities(entities) @@ -79,7 +92,7 @@ async def async_setup_entry( ) -class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): +class SmartTubOnline(SmartTubOnboardSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @@ -192,3 +205,16 @@ class SmartTubError(SmartTubEntity, BinarySensorEntity): ATTR_CREATED_AT: error.created_at.isoformat(), ATTR_UPDATED_AT: error.updated_at.isoformat(), } + + +class SmartTubCoverSensor(SmartTubExternalSensorBase, BinarySensorEntity): + """Wireless magnetic cover sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool: + """Return False if the cover is closed, True if open.""" + # magnet is True when the cover is closed, False when open + # device class OPENING wants True to mean open, False to mean closed + return not self.sensor.magnet diff --git a/homeassistant/components/smarttub/const.py b/homeassistant/components/smarttub/const.py index dadc66da942..8bf9da281a9 100644 --- a/homeassistant/components/smarttub/const.py +++ b/homeassistant/components/smarttub/const.py @@ -24,3 +24,4 @@ ATTR_LIGHTS = "lights" ATTR_PUMPS = "pumps" ATTR_REMINDERS = "reminders" ATTR_STATUS = "status" +ATTR_SENSORS = "sensors" diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index d8299bbd786..337959e0316 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -22,6 +22,7 @@ from .const import ( ATTR_LIGHTS, ATTR_PUMPS, ATTR_REMINDERS, + ATTR_SENSORS, ATTR_STATUS, DOMAIN, POLLING_TIMEOUT, @@ -108,6 +109,7 @@ class SmartTubController: ATTR_LIGHTS: {light.zone: light for light in full_status.lights}, ATTR_REMINDERS: {reminder.id: reminder for reminder in reminders}, ATTR_ERRORS: errors, + ATTR_SENSORS: {sensor.address: sensor for sensor in full_status.sensors}, } @callback diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 069fd50c5f2..53562fd887a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -2,7 +2,7 @@ from typing import Any -from smarttub import Spa, SpaState +from smarttub import Spa, SpaSensor, SpaState from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN +from .const import ATTR_SENSORS, DOMAIN from .helpers import get_spa_name @@ -47,8 +47,8 @@ class SmartTubEntity(CoordinatorEntity): return self.coordinator.data[self.spa.id].get("status") -class SmartTubSensorBase(SmartTubEntity): - """Base class for SmartTub sensors.""" +class SmartTubOnboardSensorBase(SmartTubEntity): + """Base class for SmartTub onboard sensors.""" def __init__( self, @@ -65,3 +65,29 @@ class SmartTubSensorBase(SmartTubEntity): def _state(self): """Retrieve the underlying state from the spa.""" return getattr(self.spa_status, self._state_key) + + +class SmartTubExternalSensorBase(SmartTubEntity): + """Class for additional BLE wireless sensors sold separately.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + spa: Spa, + sensor: SpaSensor, + ) -> None: + """Initialize the external sensor entity.""" + self.sensor_address = sensor.address + self._attr_unique_id = f"{spa.id}-externalsensor-{sensor.address}" + super().__init__(coordinator, spa, self._human_readable_name(sensor)) + + @staticmethod + def _human_readable_name(sensor: SpaSensor) -> str: + return " ".join( + word.capitalize() for word in sensor.name.strip("{}").split("-") + ) + + @property + def sensor(self) -> SpaSensor: + """Convenience property to access the smarttub.SpaSensor instance for this sensor.""" + return self.coordinator.data[self.spa.id][ATTR_SENSORS][self.sensor_address] diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 5116bfb3aee..64e5eec1f46 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .controller import SmartTubConfigEntry -from .entity import SmartTubSensorBase +from .entity import SmartTubOnboardSensorBase # the desired duration, in hours, of the cycle ATTR_DURATION = "duration" @@ -56,16 +56,16 @@ async def async_setup_entry( for spa in controller.spas: entities.extend( [ - SmartTubSensor(controller.coordinator, spa, "State", "state"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "State", "state"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Flow Switch", "flow_switch" ), - SmartTubSensor(controller.coordinator, spa, "Ozone", "ozone"), - SmartTubSensor(controller.coordinator, spa, "UV", "uv"), - SmartTubSensor( + SmartTubBuiltinSensor(controller.coordinator, spa, "Ozone", "ozone"), + SmartTubBuiltinSensor(controller.coordinator, spa, "UV", "uv"), + SmartTubBuiltinSensor( controller.coordinator, spa, "Blowout Cycle", "blowout_cycle" ), - SmartTubSensor( + SmartTubBuiltinSensor( controller.coordinator, spa, "Cleanup Cycle", "cleanup_cycle" ), SmartTubPrimaryFiltrationCycle(controller.coordinator, spa), @@ -90,7 +90,7 @@ async def async_setup_entry( ) -class SmartTubSensor(SmartTubSensorBase, SensorEntity): +class SmartTubBuiltinSensor(SmartTubOnboardSensorBase, SensorEntity): """Generic class for SmartTub status sensors.""" @property @@ -105,7 +105,7 @@ class SmartTubSensor(SmartTubSensorBase, SensorEntity): return self._state.lower() -class SmartTubPrimaryFiltrationCycle(SmartTubSensor): +class SmartTubPrimaryFiltrationCycle(SmartTubBuiltinSensor): """The primary filtration cycle.""" def __init__( @@ -145,7 +145,7 @@ class SmartTubPrimaryFiltrationCycle(SmartTubSensor): await self.coordinator.async_request_refresh() -class SmartTubSecondaryFiltrationCycle(SmartTubSensor): +class SmartTubSecondaryFiltrationCycle(SmartTubBuiltinSensor): """The secondary filtration cycle.""" def __init__( diff --git a/tests/components/smarttub/conftest.py b/tests/components/smarttub/conftest.py index 06780f8fb1e..f7677100aad 100644 --- a/tests/components/smarttub/conftest.py +++ b/tests/components/smarttub/conftest.py @@ -81,6 +81,16 @@ def mock_spa(spa_state): spa_state.lights = [mock_light_off, mock_light_on] + mock_cover_sensor = create_autospec(smarttub.SpaSensor, instance=True) + mock_cover_sensor.spa = mock_spa + mock_cover_sensor.address = "address1" + mock_cover_sensor.name = "{cover-sensor-1}" + mock_cover_sensor.type = "ibs0x" + mock_cover_sensor.subType = "magnet" + mock_cover_sensor.magnet = True # closed + + spa_state.sensors = [mock_cover_sensor] + mock_filter_reminder = create_autospec(smarttub.SpaReminder, instance=True) mock_filter_reminder.id = "FILTER01" mock_filter_reminder.name = "MyFilter" @@ -127,6 +137,7 @@ def mock_spa_state(): "cleanupCycle": "INACTIVE", "lights": [], "pumps": [], + "sensors": [], }, ) diff --git a/tests/components/smarttub/test_binary_sensor.py b/tests/components/smarttub/test_binary_sensor.py index 3365b03b041..cf5676aa0bb 100644 --- a/tests/components/smarttub/test_binary_sensor.py +++ b/tests/components/smarttub/test_binary_sensor.py @@ -104,3 +104,14 @@ async def test_reset_reminder(spa, setup_entry, hass: HomeAssistant) -> None: ) reminder.reset.assert_called_with(days) + + +async def test_cover_sensor(hass: HomeAssistant, spa, setup_entry) -> None: + """Test cover sensor.""" + + entity_id = f"binary_sensor.{spa.brand}_{spa.model}_cover_sensor_1" + + state = hass.states.get(entity_id) + assert state is not None + + assert state.state == STATE_OFF # closed From 971bd56bee65257704948b0ab770e917fbd1a1e5 Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 25 Jul 2025 20:37:36 +0200 Subject: [PATCH 0628/1118] Add Z-Box Hub virtual integration (#146678) --- homeassistant/components/zbox_hub/__init__.py | 1 + homeassistant/components/zbox_hub/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/zbox_hub/__init__.py create mode 100644 homeassistant/components/zbox_hub/manifest.json diff --git a/homeassistant/components/zbox_hub/__init__.py b/homeassistant/components/zbox_hub/__init__.py new file mode 100644 index 00000000000..4635546852c --- /dev/null +++ b/homeassistant/components/zbox_hub/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Z-Box Hub.""" diff --git a/homeassistant/components/zbox_hub/manifest.json b/homeassistant/components/zbox_hub/manifest.json new file mode 100644 index 00000000000..b3aa28e9af8 --- /dev/null +++ b/homeassistant/components/zbox_hub/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "zbox_hub", + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33cc637b8a8..24f72add2ec 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7660,6 +7660,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "zbox_hub": { + "name": "Z-Box Hub", + "integration_type": "virtual", + "supported_by": "fibaro" + }, "zengge": { "name": "Zengge", "integration_type": "hub", From 56fb59e48ed12f4dcfb61325ebbedd3d2aa0c557 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 25 Jul 2025 21:21:57 +0200 Subject: [PATCH 0629/1118] Unifiprotect refactor device description ID retrieval in tests (#149445) --- .../unifiprotect/test_binary_sensor.py | 52 ++++++------- tests/components/unifiprotect/test_event.py | 36 ++++----- tests/components/unifiprotect/test_number.py | 24 ++++-- .../components/unifiprotect/test_recorder.py | 4 +- tests/components/unifiprotect/test_select.py | 60 +++++++------- tests/components/unifiprotect/test_sensor.py | 78 +++++++++++-------- tests/components/unifiprotect/test_switch.py | 36 ++++++--- tests/components/unifiprotect/test_text.py | 8 +- tests/components/unifiprotect/utils.py | 34 +++++++- 9 files changed, 198 insertions(+), 134 deletions(-) diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 3aa441659b0..0c4d6e00066 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -111,8 +111,8 @@ async def test_binary_sensor_setup_light( assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) for description in LIGHT_SENSOR_WRITE: - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description ) entity = entity_registry.async_get(entity_id) @@ -139,8 +139,8 @@ async def test_binary_sensor_setup_camera_all( assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) description = EVENT_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -154,8 +154,8 @@ async def test_binary_sensor_setup_camera_all( # Is Dark description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -169,8 +169,8 @@ async def test_binary_sensor_setup_camera_all( # Motion description = EVENT_SENSORS[1] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -197,8 +197,8 @@ async def test_binary_sensor_setup_camera_none( description = CAMERA_SENSORS[0] - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, camera, description ) entity = entity_registry.async_get(entity_id) @@ -229,8 +229,8 @@ async def test_binary_sensor_setup_sensor( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -262,8 +262,8 @@ async def test_binary_sensor_setup_sensor_leak( STATE_OFF, ] for index, description in enumerate(SENSE_SENSORS_WRITE): - unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -288,8 +288,8 @@ async def test_binary_sensor_update_motion( await init_entry(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 12) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( @@ -334,8 +334,8 @@ async def test_binary_sensor_update_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) event_metadata = EventMetadata(light_id=light.id) @@ -378,8 +378,8 @@ async def test_binary_sensor_update_mount_type_window( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -410,8 +410,8 @@ async def test_binary_sensor_update_mount_type_garage( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -451,8 +451,8 @@ async def test_binary_sensor_package_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PACKAGE) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[6] ) event = Event( @@ -592,8 +592,8 @@ async def test_binary_sensor_person_detected( doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] ) events = async_capture_events(hass, EVENT_STATE_CHANGED) diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index 032a3b253a7..80b11c047cc 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -57,8 +57,8 @@ async def test_doorbell_ring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) @@ -171,8 +171,8 @@ async def test_doorbell_nfc_scanned( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -246,8 +246,8 @@ async def test_doorbell_nfc_scanned_ulpusr_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -322,8 +322,8 @@ async def test_doorbell_nfc_scanned_no_ulpusr( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) ulp_id = "ulp_id" @@ -390,8 +390,8 @@ async def test_doorbell_nfc_scanned_no_keyring( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[1] ) test_nfc_id = "test_nfc_id" @@ -451,8 +451,8 @@ async def test_doorbell_fingerprint_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -519,8 +519,8 @@ async def test_doorbell_fingerprint_identified_user_deactivated( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -588,8 +588,8 @@ async def test_doorbell_fingerprint_identified_no_user( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) ulp_id = "ulp_id" @@ -649,8 +649,8 @@ async def test_doorbell_fingerprint_not_identified( def _capture_event(event: HAEvent) -> None: events.append(event) - _, entity_id = ids_from_device_description( - Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[2] ) unsub = async_track_state_change_event(hass, entity_id, _capture_event) diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 1838a574bc4..a93c49a2ebe 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -80,8 +80,8 @@ async def test_number_setup_light( assert_entity_counts(hass, Platform.NUMBER, 2, 2) for description in LIGHT_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description ) entity = entity_registry.async_get(entity_id) @@ -111,8 +111,8 @@ async def test_number_setup_camera_all( assert_entity_counts(hass, Platform.NUMBER, 5, 5) for description in CAMERA_NUMBERS: - unique_id, entity_id = ids_from_device_description( - Platform.NUMBER, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description ) entity = entity_registry.async_get(entity_id) @@ -165,7 +165,9 @@ async def test_number_light_sensitivity( light.__pydantic_fields__["set_sensitivity"] = Mock(final=False, frozen=False) light.set_sensitivity = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -187,7 +189,9 @@ async def test_number_light_duration( light.__pydantic_fields__["set_duration"] = Mock(final=False, frozen=False) light.set_duration = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True @@ -215,7 +219,9 @@ async def test_number_camera_simple( ) setattr(camera, description.ufp_set_method, AsyncMock()) - _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1.0}, blocking=True @@ -237,7 +243,9 @@ async def test_number_lock_auto_close( ) doorlock.set_auto_close_time = AsyncMock() - _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, doorlock, description + ) await hass.services.async_call( "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 1f025a63306..c1eef3f7839 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -35,8 +35,8 @@ async def test_exclude_attributes( now = fixed_now await init_entry(hass, ufp, [doorbell, unadopted_camera]) - _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 6db3ae22dcb..f8485e678a1 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -98,8 +98,8 @@ async def test_select_setup_light( expected_values = ("On Motion - When Dark", "Not Paired") for index, description in enumerate(LIGHT_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, description ) entity = entity_registry.async_get(entity_id) @@ -127,8 +127,8 @@ async def test_select_setup_viewer( description = VIEWER_SELECTS[0] - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, viewer, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, description ) entity = entity_registry.async_get(entity_id) @@ -161,8 +161,8 @@ async def test_select_setup_camera_all( ) for index, description in enumerate(CAMERA_SELECTS): - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -192,8 +192,8 @@ async def test_select_setup_camera_none( if index == 2: return - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SELECT, camera, description ) entity = entity_registry.async_get(entity_id) @@ -215,8 +215,8 @@ async def test_select_update_liveview( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) state = hass.states.get(entity_id) @@ -252,8 +252,8 @@ async def test_select_update_doorbell_settings( expected_length = len(ufp.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -296,8 +296,8 @@ async def test_select_update_doorbell_message( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -330,7 +330,9 @@ async def test_select_set_option_light_motion( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) light.__pydantic_fields__["set_light_settings"] = Mock(final=False, frozen=False) light.set_light_settings = AsyncMock() @@ -355,7 +357,9 @@ async def test_select_set_option_light_camera( await init_entry(hass, ufp, [light, camera]) assert_entity_counts(hass, Platform.SELECT, 4, 4) - _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[1] + ) light.__pydantic_fields__["set_paired_camera"] = Mock(final=False, frozen=False) light.set_paired_camera = AsyncMock() @@ -389,8 +393,8 @@ async def test_select_set_option_camera_recording( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) doorbell.__pydantic_fields__["set_recording_mode"] = Mock(final=False, frozen=False) @@ -414,8 +418,8 @@ async def test_select_set_option_camera_ir( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[1] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) doorbell.__pydantic_fields__["set_ir_led_model"] = Mock(final=False, frozen=False) @@ -439,8 +443,8 @@ async def test_select_set_option_camera_doorbell_custom( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -466,8 +470,8 @@ async def test_select_set_option_camera_doorbell_unifi( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -508,8 +512,8 @@ async def test_select_set_option_camera_doorbell_default( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - _, entity_id = ids_from_device_description( - Platform.SELECT, doorbell, CAMERA_SELECTS[2] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) @@ -537,8 +541,8 @@ async def test_select_set_option_viewer( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - _, entity_id = ids_from_device_description( - Platform.SELECT, viewer, VIEWER_SELECTS[0] + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, viewer, VIEWER_SELECTS[0] ) viewer.__pydantic_fields__["set_liveview"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index c65b3ac8e4e..a5c6d437006 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -119,8 +119,8 @@ async def test_sensor_setup_sensor( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -133,7 +133,8 @@ async def test_sensor_setup_sensor( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # BLE signal - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, get_sensor_by_key(ALL_DEVICES_SENSORS, "ble_signal"), @@ -173,8 +174,8 @@ async def test_sensor_setup_sensor_none( for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -228,8 +229,8 @@ async def test_sensor_setup_nvr( "50", ) for index, description in enumerate(NVR_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -247,8 +248,8 @@ async def test_sensor_setup_nvr( expected_values = ("50.0", "50.0", "50.0") for index, description in enumerate(NVR_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -283,8 +284,8 @@ async def test_sensor_nvr_missing_values( # Uptime description = get_sensor_by_key(NVR_SENSORS, "uptime") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -300,8 +301,8 @@ async def test_sensor_nvr_missing_values( # Recording capacity description = get_sensor_by_key(NVR_SENSORS, "record_capacity") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -315,8 +316,8 @@ async def test_sensor_nvr_missing_values( # Memory utilization description = get_sensor_by_key(NVR_DISABLED_SENSORS, "memory_utilization") - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, description ) entity = entity_registry.async_get(entity_id) @@ -353,8 +354,8 @@ async def test_sensor_setup_camera( for index, description in enumerate(CAMERA_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -369,8 +370,8 @@ async def test_sensor_setup_camera( expected_values = ("0.0001", "0.0001") for index, description in enumerate(CAMERA_DISABLED_SENSORS): - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -386,8 +387,11 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # Wired signal (phy_rate / link speed) - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate") + unique_id, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + doorbell, + get_sensor_by_key(ALL_DEVICES_SENSORS, "phy_rate"), ) entity = entity_registry.async_get(entity_id) @@ -403,7 +407,8 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION # WiFi signal - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, get_sensor_by_key(ALL_DEVICES_SENSORS, "wifi_signal"), @@ -436,7 +441,8 @@ async def test_sensor_setup_camera_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 24, 24) # Last Trip Time - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, doorbell, get_sensor_by_key(MOTION_TRIP_SENSORS, "motion_last_trip_time"), @@ -463,8 +469,11 @@ async def test_sensor_update_alarm( await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "alarm_sound") + _, entity_id = await ids_from_device_description( + hass, + Platform.SENSOR, + sensor_all, + get_sensor_by_key(SENSE_SENSORS, "alarm_sound"), ) event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") @@ -514,7 +523,8 @@ async def test_sensor_update_alarm_with_last_trip_time( assert_entity_counts(hass, Platform.SENSOR, 22, 22) # Last Trip Time - unique_id, entity_id = ids_from_device_description( + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, get_sensor_by_key(SENSE_SENSORS, "door_last_trip_time"), @@ -547,7 +557,8 @@ async def test_camera_update_license_plate( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -664,7 +675,8 @@ async def test_camera_update_license_plate_changes_number_during_detect( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -753,7 +765,8 @@ async def test_camera_update_license_plate_multiple_updates( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -878,7 +891,8 @@ async def test_camera_update_license_no_dupes( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SENSOR, 23, 13) - _, entity_id = ids_from_device_description( + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, camera, get_sensor_by_key(LICENSE_PLATE_EVENT_SENSORS, "smart_obj_licenseplate"), @@ -973,8 +987,8 @@ async def test_sensor_precision( assert_entity_counts(hass, Platform.SENSOR, 22, 14) nvr: NVR = ufp.api.bootstrap.nvr - _, entity_id = ids_from_device_description( - Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, nvr, get_sensor_by_key(NVR_SENSORS, "resolution_4K") ) assert hass.states.get(entity_id).state == "17.49" diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1a899550204..501418948c6 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -135,8 +135,8 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[1] - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, light, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description ) entity = entity_registry.async_get(entity_id) @@ -178,8 +178,8 @@ async def test_switch_setup_camera_all( assert_entity_counts(hass, Platform.SWITCH, 17, 15) for description in CAMERA_SWITCHES_BASIC: - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -224,8 +224,8 @@ async def test_switch_setup_camera_none( if description.ufp_required_field is not None: continue - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, camera, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, camera, description ) entity = entity_registry.async_get(entity_id) @@ -268,7 +268,9 @@ async def test_switch_light_status( light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False) light.set_status_light = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -296,7 +298,9 @@ async def test_switch_camera_ssh( doorbell.__pydantic_fields__["set_ssh"] = Mock(final=False, frozen=False) doorbell.set_ssh = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await enable_entity(hass, ufp.entry.entry_id, entity_id) await hass.services.async_call( @@ -332,7 +336,9 @@ async def test_switch_camera_simple( setattr(doorbell, description.ufp_set_method, AsyncMock()) set_method = getattr(doorbell, description.ufp_set_method) - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -360,7 +366,9 @@ async def test_switch_camera_highfps( doorbell.__pydantic_fields__["set_video_mode"] = Mock(final=False, frozen=False) doorbell.set_video_mode = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -391,7 +399,9 @@ async def test_switch_camera_privacy( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) state = hass.states.get(entity_id) assert state and state.state == "off" @@ -443,7 +453,9 @@ async def test_switch_camera_privacy_already_on( doorbell.__pydantic_fields__["set_privacy"] = Mock(final=False, frozen=False) doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, doorbell, description + ) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index c34611c43a9..99f16fcbb75 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -51,8 +51,8 @@ async def test_text_camera_setup( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -74,8 +74,8 @@ async def test_text_camera_set( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = ids_from_device_description( - Platform.TEXT, doorbell, description + unique_id, entity_id = await ids_from_device_description( + hass, Platform.TEXT, doorbell, description ) doorbell.__pydantic_fields__["set_lcd_text"] = Mock(final=False, frozen=False) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index ddd6fdf0189..6514f672d90 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -23,7 +23,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.helpers.entity import EntityDescription from homeassistant.util import dt as dt_util @@ -100,17 +100,43 @@ def normalize_name(name: str) -> str: return name.lower().replace(":", "").replace(" ", "_").replace("-", "_") -def ids_from_device_description( +async def async_get_translated_entity_name( + hass: HomeAssistant, platform: Platform, translation_key: str +) -> str: + """Get the translated entity name for a given platform and translation key.""" + platform_name = "unifiprotect" + + # Get the translations for the UniFi Protect integration + translations = await translation.async_get_translations( + hass, "en", "entity", {platform_name} + ) + + # Build the translation key in the format that Home Assistant uses + # component.{integration}.entity.{platform}.{translation_key}.name + full_translation_key = ( + f"component.{platform_name}.entity.{platform.value}.{translation_key}.name" + ) + + # Get the translated name, fall back to the translation key if not found + return translations.get(full_translation_key, translation_key) + + +async def ids_from_device_description( + hass: HomeAssistant, platform: Platform, device: ProtectAdoptableDeviceModel, description: EntityDescription, ) -> tuple[str, str]: - """Return expected unique_id and entity_id for a give platform/device/description combination.""" + """Return expected unique_id and entity_id using real Home Assistant translation logic.""" entity_name = normalize_name(device.display_name) if getattr(description, "translation_key", None): - description_entity_name = normalize_name(description.translation_key) + # Get the actual translated name from Home Assistant + translated_name = await async_get_translated_entity_name( + hass, platform, description.translation_key + ) + description_entity_name = normalize_name(translated_name) elif getattr(description, "device_class", None): description_entity_name = normalize_name(description.device_class) else: From cbf4409db38070bac85cbeab0c79a0f95a96f737 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 25 Jul 2025 21:51:01 +0200 Subject: [PATCH 0630/1118] Fix inconsistent spelling of "Wi-Fi" in `unifiprotect` (#149311) Co-authored-by: J. Nick Koston --- homeassistant/components/unifiprotect/strings.json | 2 +- tests/components/unifiprotect/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index f20b56d29e4..9289d0f66d4 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -357,7 +357,7 @@ "name": "Link speed" }, "wifi_signal_strength": { - "name": "WiFi signal strength" + "name": "Wi-Fi signal strength" }, "oldest_recording": { "name": "Oldest recording" diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index a5c6d437006..75193a491c9 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -406,7 +406,7 @@ async def test_sensor_setup_camera( assert state.state == "1000" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # WiFi signal + # Wi-Fi signal unique_id, entity_id = await ids_from_device_description( hass, Platform.SENSOR, From aab7381553dbb5019521b25b98e280bde00b354f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 26 Jul 2025 00:27:04 +0200 Subject: [PATCH 0631/1118] Add test of ConfigSubentryFlow._subentry_type (#147565) --- tests/test_config_entries.py | 37 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9666e8ba1c4..833d28ecdd9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8008,7 +8008,10 @@ async def test_get_reconfigure_entry( async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior. + + Also tests related helpers _entry_id, _subentry_type, _reconfigure_subentry_id + """ subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8044,18 +8047,8 @@ async def test_subentry_get_entry( async def _async_step_confirm(self): """Confirm input.""" - try: - entry = self._get_entry() - except ValueError as err: - reason = str(err) - else: - reason = f"Found entry {entry.title}" - try: - entry_id = self._entry_id - except ValueError: - reason = f"{reason}: -" - else: - reason = f"{reason}: {entry_id}" + reason = f"Found entry {self._get_entry().title},{self._entry_id}: " + reason = f"{reason}subentry_type={self._subentry_type}" try: subentry = self._get_reconfigure_subentry() @@ -8083,9 +8076,9 @@ async def test_subentry_get_entry( # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Found subentry Test: mock_subentry_id" ) # The subentry_id does not exist @@ -8097,9 +8090,9 @@ async def test_subentry_get_entry( "subentry_id": "01JRemoved", }, ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Subentry not found: 01JRemoved" ) # A user flow finds the config entry but not the subentry @@ -8107,9 +8100,9 @@ async def test_subentry_get_entry( result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) - assert ( - result["reason"] - == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" + assert result["reason"] == ( + "Found entry entry_title,mock_entry_id: subentry_type=test/" + "Source is user, expected reconfigure: -" ) From e017dc80a0097705e77f429f7e8f984fb02cb778 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 26 Jul 2025 01:07:51 +0200 Subject: [PATCH 0632/1118] Allow to reorder members within a group (#149003) --- homeassistant/components/group/config_flow.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index ee8d11d035d..5e36087e9e4 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -56,12 +56,12 @@ async def basic_group_options_schema( entity_selector: selector.Selector[Any] | vol.Schema if handler is None: entity_selector = selector.selector( - {"entity": {"domain": domain, "multiple": True}} + {"entity": {"domain": domain, "multiple": True, "reorder": True}} ) else: entity_selector = entity_selector_without_own_entities( cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True), ) return vol.Schema( @@ -78,7 +78,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema: { vol.Required("name"): selector.TextSelector(), vol.Required(CONF_ENTITIES): selector.EntitySelector( - selector.EntitySelectorConfig(domain=domain, multiple=True), + selector.EntitySelectorConfig( + domain=domain, multiple=True, reorder=True + ), ), vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } From 002b7c6789717df71659e02c25850f7b5ac17a11 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 09:47:26 +0200 Subject: [PATCH 0633/1118] Fix descriptions in `home_connect.set_program_and_options` action (#149462) --- homeassistant/components/home_connect/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 0b094a9d49a..fa24177a967 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -582,7 +582,7 @@ }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", - "description": "Defines the favoured cleaning mode." + "description": "Defines the favored cleaning mode." }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", @@ -670,7 +670,7 @@ }, "cooking_oven_option_setpoint_temperature": { "name": "Setpoint temperature", - "description": "Defines the target cavity temperature, which will be hold by the oven." + "description": "Defines the target cavity temperature, which will be held by the oven." }, "b_s_h_common_option_duration": { "name": "Duration", From c5cf9b07b7fa5df4908cba537462f8303763877c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 12:34:24 +0200 Subject: [PATCH 0634/1118] Replace HA alarm (control panel) states with references in `risco` (#149466) --- homeassistant/components/risco/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 86d131b4f80..22ed3ff4e52 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -45,7 +45,7 @@ }, "risco_to_ha": { "title": "Map Risco states to Home Assistant states", - "description": "Select what state your Home Assistant alarm will report for every state reported by Risco", + "description": "Select what state your Home Assistant alarm control panel will report for every state reported by Risco", "data": { "arm": "Armed (AWAY)", "partial_arm": "Partially Armed (STAY)", @@ -57,12 +57,12 @@ }, "ha_to_risco": { "title": "Map Home Assistant states to Risco states", - "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm", + "description": "Select what state to set your Risco alarm to when arming the Home Assistant alarm control panel", "data": { - "armed_away": "Armed Away", - "armed_home": "Armed Home", - "armed_night": "Armed Night", - "armed_custom_bypass": "Armed Custom Bypass" + "armed_away": "[%key:component::alarm_control_panel::entity_component::_::state::armed_away%]", + "armed_home": "[%key:component::alarm_control_panel::entity_component::_::state::armed_home%]", + "armed_night": "[%key:component::alarm_control_panel::entity_component::_::state::armed_night%]", + "armed_custom_bypass": "[%key:component::alarm_control_panel::entity_component::_::state::armed_custom_bypass%]" } } } From be5109fddf26cae70ff101e60568f9bfc59afb29 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 26 Jul 2025 12:35:11 +0200 Subject: [PATCH 0635/1118] Change spelling of "Favorite x" to intl. English in `bang_olufsen` (#149464) --- homeassistant/components/bang_olufsen/strings.json | 8 ++++---- tests/components/bang_olufsen/snapshots/test_event.ambr | 8 ++++---- tests/components/bang_olufsen/test_event.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 422dc4be567..bacd32fa77e 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -93,7 +93,7 @@ } }, "preset1": { - "name": "Favourite 1", + "name": "Favorite 1", "state_attributes": { "event_type": { "state": { @@ -107,7 +107,7 @@ } }, "preset2": { - "name": "Favourite 2", + "name": "Favorite 2", "state_attributes": { "event_type": { "state": { @@ -121,7 +121,7 @@ } }, "preset3": { - "name": "Favourite 3", + "name": "Favorite 3", "state_attributes": { "event_type": { "state": { @@ -135,7 +135,7 @@ } }, "preset4": { - "name": "Favourite 4", + "name": "Favorite 4", "state_attributes": { "event_type": { "state": { diff --git a/tests/components/bang_olufsen/snapshots/test_event.ambr b/tests/components/bang_olufsen/snapshots/test_event.ambr index 3b748d3a27a..a7fc2c88e49 100644 --- a/tests/components/bang_olufsen/snapshots/test_event.ambr +++ b/tests/components/bang_olufsen/snapshots/test_event.ambr @@ -5,10 +5,10 @@ 'event.beosound_balance_11111111_microphone', 'event.beosound_balance_11111111_next', 'event.beosound_balance_11111111_play_pause', - 'event.beosound_balance_11111111_favourite_1', - 'event.beosound_balance_11111111_favourite_2', - 'event.beosound_balance_11111111_favourite_3', - 'event.beosound_balance_11111111_favourite_4', + 'event.beosound_balance_11111111_favorite_1', + 'event.beosound_balance_11111111_favorite_2', + 'event.beosound_balance_11111111_favorite_3', + 'event.beosound_balance_11111111_favorite_4', 'event.beosound_balance_11111111_previous', 'event.beosound_balance_11111111_volume', 'media_player.beosound_balance_11111111', diff --git a/tests/components/bang_olufsen/test_event.py b/tests/components/bang_olufsen/test_event.py index 11f337b715f..1e5546ac5f2 100644 --- a/tests/components/bang_olufsen/test_event.py +++ b/tests/components/bang_olufsen/test_event.py @@ -32,7 +32,7 @@ async def test_button_event_creation( # Add Button Event entity ids entity_ids = [ f"event.beosound_balance_11111111_{underscore(button_type)}".replace( - "preset", "favourite_" + "preset", "favorite_" ) for button_type in DEVICE_BUTTONS ] From e1501d7510609e640f0daefe649c297a3c5427fa Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Sat, 26 Jul 2025 12:38:38 +0200 Subject: [PATCH 0636/1118] Bump pysuezV2 to 2.0.7 (#149436) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/suez_water/conftest.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 9149f216563..5c23240ce91 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.5"] + "requirements": ["pysuezV2==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index f308f39aa86..2229ee3e3a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2385,7 +2385,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daac6214663..5462957f60c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 9d29191289e..005c14b7458 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -87,5 +87,7 @@ def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result - suez_client.get_price.return_value = PriceResult("4.74") + suez_client.get_price.return_value = PriceResult( + "OK", {"price": 4.74}, "Price is 4.74" + ) yield suez_client From 5aa0d0dc81b5270877d6f0746bb0653c225aee40 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 26 Jul 2025 14:32:51 +0300 Subject: [PATCH 0637/1118] Remove Shelly redundant device info assignment in Button class (#149469) --- homeassistant/components/shelly/button.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 2ab23441c98..bd42af002d3 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -19,7 +19,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -251,9 +250,6 @@ class ShellyButton(ShellyBaseButton): coordinator.model_name, suggested_area=coordinator.suggested_area, ) - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} - ) async def _press_method(self) -> None: """Press method.""" From 7976729e76f0903565af0873d54ab59db8780ea9 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sat, 26 Jul 2025 14:19:33 +0200 Subject: [PATCH 0638/1118] Paperless-ngx: Retry setup on initialization error (#149476) --- homeassistant/components/paperless_ngx/__init__.py | 2 +- tests/components/paperless_ngx/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 0fea90b7ea3..da990be7173 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -96,7 +96,7 @@ async def _get_paperless_api( translation_key="forbidden", ) from err except InitializationError as err: - raise ConfigEntryError( + raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err diff --git a/tests/components/paperless_ngx/test_init.py b/tests/components/paperless_ngx/test_init.py index fd459213ea0..924e3966c79 100644 --- a/tests/components/paperless_ngx/test_init.py +++ b/tests/components/paperless_ngx/test_init.py @@ -63,7 +63,7 @@ async def test_load_config_status_forbidden( "user_inactive_or_deleted", ), (PaperlessForbiddenError(), ConfigEntryState.SETUP_ERROR, "forbidden"), - (InitializationError(), ConfigEntryState.SETUP_ERROR, "cannot_connect"), + (InitializationError(), ConfigEntryState.SETUP_RETRY, "cannot_connect"), ], ) async def test_setup_config_error_handling( From b6bd92ed192ec17183d08284823c9d8b808bbc79 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 26 Jul 2025 17:08:08 +0300 Subject: [PATCH 0639/1118] Shelly entity device info code quality (#149477) --- homeassistant/components/shelly/button.py | 27 +------ homeassistant/components/shelly/climate.py | 13 +--- homeassistant/components/shelly/entity.py | 88 ++++++++++------------ homeassistant/components/shelly/event.py | 13 +--- homeassistant/components/shelly/sensor.py | 13 +--- 5 files changed, 52 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index bd42af002d3..209fa4af54a 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -25,13 +25,8 @@ from homeassistant.util import slugify from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import ( - get_block_device_info, - get_blu_trv_device_info, - get_device_entry_gen, - get_rpc_device_info, - get_rpc_key_ids, -) +from .entity import get_entity_block_device_info, get_entity_rpc_device_info +from .utils import get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_ids PARALLEL_UPDATES = 0 @@ -233,23 +228,9 @@ class ShellyButton(ShellyBaseButton): self._attr_unique_id = f"{coordinator.mac}_{description.key}" if isinstance(coordinator, ShellyBlockCoordinator): - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) else: - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator) async def _press_method(self) -> None: """Press method.""" diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 2a09e867dce..3a495c9f4ac 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -38,10 +38,9 @@ from .const import ( SHTRV_01_TEMPERATURE_SETTINGS, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyRpcEntity, rpc_call +from .entity import ShellyRpcEntity, get_entity_block_device_info, rpc_call from .utils import ( async_remove_shelly_entity, - get_block_device_info, get_block_entity_name, get_blu_trv_device_info, get_device_entry_gen, @@ -210,15 +209,7 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - sensor_block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, sensor_block) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None ) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 33a45a0e10f..97946ddd8f3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -13,6 +13,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry @@ -368,15 +369,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) self._attr_unique_id = f"{coordinator.mac}-{block.description}" # pylint: disable-next=hass-missing-super-call @@ -417,15 +410,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -539,14 +524,7 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): coordinator.device, None, description.name ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator) self._last_value = None @property @@ -653,15 +631,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_device_info = get_block_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - block, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_block_device_info(coordinator, block) if block is not None: self._attr_unique_id = ( @@ -726,18 +696,8 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) - self._attr_unique_id = self._attr_unique_id = ( - f"{coordinator.mac}-{key}-{attribute}" - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) + self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}" self._last_value = None if coordinator.device.initialized: @@ -763,3 +723,37 @@ def get_entity_class( return description.entity_class return sensor_class + + +def get_entity_block_device_info( + coordinator: ShellyBlockCoordinator, + block: Block | None = None, +) -> DeviceInfo: + """Get device info for block entities.""" + return get_block_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + block, + suggested_area=coordinator.suggested_area, + ) + + +def get_entity_rpc_device_info( + coordinator: ShellyRpcCoordinator, + key: str | None = None, + emeter_phase: str | None = None, +) -> DeviceInfo: + """Get device info for RPC entities.""" + return get_rpc_device_info( + coordinator.device, + coordinator.mac, + coordinator.configuration_url, + coordinator.model, + coordinator.model_name, + key, + emeter_phase=emeter_phase, + suggested_area=coordinator.suggested_area, + ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 9e1c748c790..8b2b92e11ce 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -26,12 +26,11 @@ from .const import ( SHIX3_1_INPUTS_EVENTS_TYPES, ) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import ShellyBlockEntity +from .entity import ShellyBlockEntity, get_entity_rpc_device_info from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, is_block_momentary_input, @@ -206,15 +205,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Initialize Shelly entity.""" super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - suggested_area=coordinator.suggested_area, - ) + self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) self.entity_description = description diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cdfa97357f2..49e3d4773c7 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -52,13 +52,13 @@ from .entity import ( async_setup_entry_attribute_entities, async_setup_entry_rest, async_setup_entry_rpc, + get_entity_rpc_device_info, ) from .utils import ( async_remove_orphaned_entities, get_blu_trv_device_info, get_device_entry_gen, get_device_uptime, - get_rpc_device_info, get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, @@ -138,15 +138,8 @@ class RpcEmeterPhaseSensor(RpcSensor): """Initialize select.""" super().__init__(coordinator, key, attribute, description) - self._attr_device_info = get_rpc_device_info( - coordinator.device, - coordinator.mac, - coordinator.configuration_url, - coordinator.model, - coordinator.model_name, - key, - emeter_phase=description.emeter_phase, - suggested_area=coordinator.suggested_area, + self._attr_device_info = get_entity_rpc_device_info( + coordinator, key, emeter_phase=description.emeter_phase ) From 427e5d81dfd180bcb5031d1c7468981620ae3eaa Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Sat, 26 Jul 2025 19:03:51 +0300 Subject: [PATCH 0640/1118] Bump pyituran to 0.1.5 (#149486) --- homeassistant/components/ituran/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ituran/manifest.json b/homeassistant/components/ituran/manifest.json index 0cf20d3c6b2..d63ca2fef84 100644 --- a/homeassistant/components/ituran/manifest.json +++ b/homeassistant/components/ituran/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["pyituran==0.1.4"] + "requirements": ["pyituran==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2229ee3e3a0..b20cdaa61c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2068,7 +2068,7 @@ pyisy==3.4.1 pyitachip2ir==0.0.7 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5462957f60c..a9d22851d43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1719,7 +1719,7 @@ pyiss==1.0.1 pyisy==3.4.1 # homeassistant.components.ituran -pyituran==0.1.4 +pyituran==0.1.5 # homeassistant.components.jvc_projector pyjvcprojector==1.1.2 From 27bd6d2e385df953205a70b120be7b7b3af14c7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 26 Jul 2025 22:48:48 -1000 Subject: [PATCH 0641/1118] Bump aioesphomeapi to 37.1.2 (#149460) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e83ab16064c..17fd72fc939 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.0.2", + "aioesphomeapi==37.1.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b20cdaa61c6..4f873d3a9be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.2 +aioesphomeapi==37.1.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9d22851d43..d6c85395d5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.0.2 +aioesphomeapi==37.1.2 # homeassistant.components.flo aioflo==2021.11.0 From 57b641b97d908490bf1643682fa445e623f35f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 27 Jul 2025 11:43:48 +0100 Subject: [PATCH 0642/1118] Use non-autospec mock in Reolink's media source, number, sensor and siren tests (#149396) --- tests/components/reolink/conftest.py | 7 +++ tests/components/reolink/test_media_source.py | 58 +++++++++---------- tests/components/reolink/test_number.py | 51 +++++++--------- tests/components/reolink/test_sensor.py | 22 +++---- tests/components/reolink/test_siren.py | 26 ++++----- 5 files changed, 77 insertions(+), 87 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a5f528edef6..d699d1b9102 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -67,6 +67,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.get_host_data = AsyncMock(return_value=None) host_mock.get_states = AsyncMock(return_value=None) host_mock.get_state = AsyncMock() + host_mock.async_get_time = AsyncMock() host_mock.check_new_firmware = AsyncMock(return_value=False) host_mock.subscribe = AsyncMock() host_mock.unsubscribe = AsyncMock(return_value=True) @@ -80,12 +81,16 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.pull_point_request = AsyncMock() host_mock.set_audio = AsyncMock() host_mock.set_email = AsyncMock() + host_mock.set_siren = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() host_mock.set_whiteled = AsyncMock() host_mock.set_state_light = AsyncMock() host_mock.renew = AsyncMock() host_mock.get_vod_source = AsyncMock() + host_mock.request_vod_files = AsyncMock() host_mock.expire_session = AsyncMock() + host_mock.set_volume = AsyncMock() + host_mock.set_hub_audio = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -168,6 +173,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, } + host_mock.baichuan.set_smart_ai = AsyncMock() host_mock.baichuan.smart_location_list.return_value = [0] host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 @@ -281,6 +287,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: "visitor": {"switch": 1, "musicId": 2}, } TEST_CHIME.remove = AsyncMock() + TEST_CHIME.set_option = AsyncMock() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 31da3b213be..0308639499c 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -89,7 +89,7 @@ async def test_platform_loads_before_config_entry( async def test_resolve( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: @@ -99,7 +99,7 @@ async def test_resolve( caplog.set_level(logging.DEBUG) file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -107,14 +107,14 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None ) assert play_media.mime_type == TEST_MIME_TYPE_MP4 - reolink_connect.is_nvr = False + reolink_host.is_nvr = False play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -122,7 +122,7 @@ async def test_resolve( assert play_media.mime_type == TEST_MIME_TYPE_MP4 file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}|{TEST_START}|{TEST_END}" - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + reolink_host.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None @@ -132,16 +132,16 @@ async def test_resolve( async def test_browsing( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test browsing the Reolink three.""" entry_id = config_entry.entry_id - reolink_connect.supported.return_value = 1 - reolink_connect.model = "Reolink TrackMix PoE" - reolink_connect.is_nvr = False + reolink_host.supported.return_value = 1 + reolink_host.model = "Reolink TrackMix PoE" + reolink_host.is_nvr = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -184,7 +184,7 @@ async def test_browsing( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_host.request_vod_files.return_value = ([mock_status], []) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") assert browse.domain == DOMAIN @@ -223,7 +223,7 @@ async def test_browsing( mock_vod_file.duration = timedelta(minutes=5) mock_vod_file.file_name = TEST_FILE_NAME mock_vod_file.triggers = VOD_trigger.PERSON - reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + reolink_host.request_vod_files.return_value = ([mock_status], [mock_vod_file]) browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -236,7 +236,7 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -245,10 +245,10 @@ async def test_browsing( trigger=None, ) - reolink_connect.model = TEST_HOST_MODEL + reolink_host.model = TEST_HOST_MODEL # browse event trigger person on a NVR - reolink_connect.is_nvr = True + reolink_host.is_nvr = True browse_event_person_id = f"EVE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}|{VOD_trigger.PERSON.name}" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") @@ -265,7 +265,7 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id - reolink_connect.request_vod_files.assert_called_with( + reolink_host.request_vod_files.assert_called_with( int(TEST_CHANNEL), TEST_START_TIME, TEST_END_TIME, @@ -274,17 +274,15 @@ async def test_browsing( trigger=VOD_trigger.PERSON, ) - reolink_connect.is_nvr = False - async def test_browsing_h265_encoding( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id - reolink_connect.is_nvr = True + reolink_host.is_nvr = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -296,10 +294,10 @@ async def test_browsing_h265_encoding( mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH mock_status.days = (TEST_DAY, TEST_DAY2) - reolink_connect.request_vod_files.return_value = ([mock_status], []) - reolink_connect.time.return_value = None - reolink_connect.get_encoding.return_value = "h265" - reolink_connect.supported.return_value = False + reolink_host.request_vod_files.return_value = ([mock_status], []) + reolink_host.time.return_value = None + reolink_host.get_encoding.return_value = "h265" + reolink_host.supported.return_value = False browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") @@ -331,7 +329,7 @@ async def test_browsing_h265_encoding( async def test_browsing_rec_playback_unsupported( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera which does not support playback of recordings.""" @@ -342,7 +340,7 @@ async def test_browsing_rec_playback_unsupported( return False return True - reolink_connect.supported = test_supported + reolink_host.supported = test_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -356,12 +354,10 @@ async def test_browsing_rec_playback_unsupported( assert browse.identifier is None assert browse.children == [] - reolink_connect.supported = lambda ch, key: True # Reset supported function - async def test_browsing_errors( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera errors.""" @@ -378,7 +374,7 @@ async def test_browsing_errors( async def test_browsing_not_loaded( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera integration which is not loaded.""" @@ -386,7 +382,7 @@ async def test_browsing_not_loaded( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.get_host_data.side_effect = ReolinkError("Test error") config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=format_mac(TEST_MAC2), @@ -414,5 +410,3 @@ async def test_browsing_not_loaded( assert browse.title == "Reolink" assert browse.identifier is None assert len(browse.children) == 1 - - reolink_connect.get_host_data.side_effect = None diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index dd70376d658..17fc2797479 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -1,6 +1,6 @@ """Test the Reolink number platform.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from reolink_aio.api import Chime @@ -24,10 +24,10 @@ from tests.common import MockConfigEntry async def test_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.volume.return_value = 80 + reolink_host.volume.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -44,9 +44,9 @@ async def test_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=50) + reolink_host.set_volume.assert_called_with(0, volume=50) - reolink_connect.set_volume.side_effect = ReolinkError("Test error") + reolink_host.set_volume.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -55,7 +55,7 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.side_effect = InvalidParameterError("Test error") + reolink_host.set_volume.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -64,17 +64,15 @@ async def test_number( blocking=True, ) - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_smart_ai_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with smart ai sensitivity.""" - reolink_connect.baichuan.smart_ai_sensitivity.return_value = 80 + reolink_host.baichuan.smart_ai_sensitivity.return_value = 80 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -91,13 +89,11 @@ async def test_smart_ai_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, blocking=True, ) - reolink_connect.baichuan.set_smart_ai.assert_called_with( + reolink_host.baichuan.set_smart_ai.assert_called_with( 0, "crossline", 0, sensitivity=50 ) - reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( - "Test error" - ) + reolink_host.baichuan.set_smart_ai.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -106,16 +102,14 @@ async def test_smart_ai_number( blocking=True, ) - reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) - async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test number entity with volume.""" - reolink_connect.alarm_volume = 85 + reolink_host.alarm_volume = 85 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -132,9 +126,9 @@ async def test_host_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, blocking=True, ) - reolink_connect.set_hub_audio.assert_called_with(alarm_volume=45) + reolink_host.set_hub_audio.assert_called_with(alarm_volume=45) - reolink_connect.set_hub_audio.side_effect = ReolinkError("Test error") + reolink_host.set_hub_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -143,7 +137,7 @@ async def test_host_number( blocking=True, ) - reolink_connect.set_hub_audio.side_effect = InvalidParameterError("Test error") + reolink_host.set_hub_audio.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -156,11 +150,11 @@ async def test_host_number( async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, ) -> None: """Test number entity of a chime with chime volume.""" - test_chime.volume = 3 + reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -171,16 +165,15 @@ async def test_chime_number( assert hass.states.get(entity_id).state == "3" - test_chime.set_option = AsyncMock() await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 2}, blocking=True, ) - test_chime.set_option.assert_called_with(volume=2) + reolink_chime.set_option.assert_called_with(volume=2) - test_chime.set_option.side_effect = ReolinkError("Test error") + reolink_chime.set_option.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -189,7 +182,7 @@ async def test_chime_number( blocking=True, ) - test_chime.set_option.side_effect = InvalidParameterError("Test error") + reolink_chime.set_option.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( NUMBER_DOMAIN, @@ -197,5 +190,3 @@ async def test_chime_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, blocking=True, ) - - test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index c3fe8d89951..b30f0c2a61a 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -17,14 +17,14 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test sensor entities.""" - reolink_connect.ptz_pan_position.return_value = 1200 - reolink_connect.wifi_connection = True - reolink_connect.wifi_signal.return_value = -55 - reolink_connect.hdd_list = [0] - reolink_connect.hdd_storage.return_value = 95 + reolink_host.ptz_pan_position.return_value = 1200 + reolink_host.wifi_connection = True + reolink_host.wifi_signal.return_value = -55 + reolink_host.hdd_list = [0] + reolink_host.hdd_storage.return_value = 95 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -45,13 +45,13 @@ async def test_sensors( async def test_hdd_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test hdd sensor entity.""" - reolink_connect.hdd_list = [0] - reolink_connect.hdd_type.return_value = "HDD" - reolink_connect.hdd_storage.return_value = 85 - reolink_connect.hdd_available.return_value = False + reolink_host.hdd_list = [0] + reolink_host.hdd_type.return_value = "HDD" + reolink_host.hdd_storage.return_value = 85 + reolink_host.hdd_available.return_value = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index f6ba8e0ea77..43156626b12 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -30,7 +30,7 @@ from tests.common import MockConfigEntry async def test_siren( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test siren entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -48,8 +48,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_volume.assert_not_called() - reolink_connect.set_siren.assert_called_with(0, True, None) + reolink_host.set_volume.assert_not_called() + reolink_host.set_siren.assert_called_with(0, True, None) await hass.services.async_call( SIREN_DOMAIN, @@ -57,8 +57,8 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id, ATTR_VOLUME_LEVEL: 0.85, ATTR_DURATION: 2}, blocking=True, ) - reolink_connect.set_volume.assert_called_with(0, volume=85) - reolink_connect.set_siren.assert_called_with(0, True, 2) + reolink_host.set_volume.assert_called_with(0, 85) + reolink_host.set_siren.assert_called_with(0, True, 2) # test siren turn off await hass.services.async_call( @@ -67,7 +67,7 @@ async def test_siren( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_siren.assert_called_with(0, False, None) + reolink_host.set_siren.assert_called_with(0, False, None) @pytest.mark.parametrize("attr", ["set_volume", "set_siren"]) @@ -87,7 +87,7 @@ async def test_siren( async def test_siren_turn_on_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: Any, @@ -100,8 +100,8 @@ async def test_siren_turn_on_errors( entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + original = getattr(reolink_host, attr) + setattr(reolink_host, attr, value) with pytest.raises(expected): await hass.services.async_call( SIREN_DOMAIN, @@ -110,13 +110,13 @@ async def test_siren_turn_on_errors( blocking=True, ) - setattr(reolink_connect, attr, original) + setattr(reolink_host, attr, original) async def test_siren_turn_off_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors when calling siren turn off service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SIREN]): @@ -126,7 +126,7 @@ async def test_siren_turn_off_errors( entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" - reolink_connect.set_siren.side_effect = ReolinkError("Test error") + reolink_host.set_siren.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SIREN_DOMAIN, @@ -134,5 +134,3 @@ async def test_siren_turn_off_errors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.set_siren.reset_mock(side_effect=True) From 22d0fbcbd2a33dfff9a274bdccf5bfadeefff54a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 27 Jul 2025 14:39:21 +0200 Subject: [PATCH 0643/1118] Fix spelling of "its" in `mqtt` (#149517) --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ba869a7334b..92900d8292d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -136,7 +136,7 @@ "step": { "availability": { "title": "Availability options", - "description": "The availability feature allows a device to report it's availability.", + "description": "The availability feature allows a device to report its availability.", "data": { "availability_topic": "Availability topic", "availability_template": "Availability template", From 0e9ced3c00074c1adecbbcafa9ba6274ae56aa2f Mon Sep 17 00:00:00 2001 From: petep0p Date: Sun, 27 Jul 2025 06:13:31 -0700 Subject: [PATCH 0644/1118] Correct core Purpleair integration's RSSI sensor to use RSSI value rather than barometric pressure (#149418) --- homeassistant/components/purpleair/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index a85a23b6144..3a2e42e63cb 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -132,7 +132,7 @@ SENSOR_DESCRIPTIONS = [ entity_registry_enabled_default=False, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda sensor: sensor.pressure, + value_fn=lambda sensor: sensor.rssi, ), PurpleAirSensorEntityDescription( key="temperature", From dac75d19026f5202c5268d0a646091272d856156 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:02:33 +0200 Subject: [PATCH 0645/1118] Add update platform to Uptime Kuma (#148973) --- .../components/uptime_kuma/__init__.py | 32 ++++- .../components/uptime_kuma/coordinator.py | 32 ++++- .../components/uptime_kuma/strings.json | 8 ++ .../components/uptime_kuma/update.py | 122 ++++++++++++++++++ tests/components/uptime_kuma/conftest.py | 20 +++ .../uptime_kuma/snapshots/test_update.ambr | 61 +++++++++ tests/components/uptime_kuma/test_update.py | 77 +++++++++++ 7 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/uptime_kuma/update.py create mode 100644 tests/components/uptime_kuma/snapshots/test_update.ambr create mode 100644 tests/components/uptime_kuma/test_update.py diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 0215c83f0cc..68234077976 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -2,16 +2,37 @@ from __future__ import annotations +from pythonkuma.update import UpdateChecker + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.hass_dict import HassKey -from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Set up Uptime Kuma from a config entry.""" + if UPTIME_KUMA_KEY not in hass.data: + session = async_get_clientsession(hass) + update_checker = UpdateChecker(session) + + update_coordinator = UptimeKumaSoftwareUpdateCoordinator(hass, update_checker) + await update_coordinator.async_request_refresh() + + hass.data[UPTIME_KUMA_KEY] = update_coordinator coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -24,4 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[UPTIME_KUMA_KEY].async_shutdown() + hass.data.pop(UPTIME_KUMA_KEY) + return unload_ok diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 297bd83e7c8..58eed420fd8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -6,12 +6,14 @@ from datetime import timedelta import logging from pythonkuma import ( + UpdateException, UptimeKuma, UptimeKumaAuthenticationException, UptimeKumaException, UptimeKumaMonitor, UptimeKumaVersion, ) +from pythonkuma.update import LatestRelease, UpdateChecker from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -25,6 +27,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL_UPDATES = timedelta(hours=3) + type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] @@ -45,7 +50,7 @@ class UptimeKumaDataUpdateCoordinator( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=SCAN_INTERVAL, ) session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) self.api = UptimeKuma( @@ -105,3 +110,28 @@ def async_migrate_entities_unique_ids( registry_entry.entity_id, new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", ) + + +class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """Uptime Kuma coordinator for retrieving update information.""" + + def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=SCAN_INTERVAL_UPDATES, + ) + self.update_checker = update_checker + + async def _async_update_data(self) -> LatestRelease: + """Fetch data.""" + try: + return await self.update_checker.latest_release() + except UpdateException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_check_failed", + ) from e diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 87dcf6e8cf7..62b1ccbdd9a 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -106,6 +106,11 @@ "port": { "name": "Monitored port" } + }, + "update": { + "update": { + "name": "Uptime Kuma version" + } } }, "exceptions": { @@ -114,6 +119,9 @@ }, "request_failed_exception": { "message": "Connection to Uptime Kuma failed" + }, + "update_check_failed": { + "message": "Failed to check for latest Uptime Kuma update" } } } diff --git a/homeassistant/components/uptime_kuma/update.py b/homeassistant/components/uptime_kuma/update.py new file mode 100644 index 00000000000..6fe4e477f0b --- /dev/null +++ b/homeassistant/components/uptime_kuma/update.py @@ -0,0 +1,122 @@ +"""Update platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from enum import StrEnum + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import UPTIME_KUMA_KEY +from .const import DOMAIN +from .coordinator import ( + UptimeKumaConfigEntry, + UptimeKumaDataUpdateCoordinator, + UptimeKumaSoftwareUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +class UptimeKumaUpdate(StrEnum): + """Uptime Kuma update.""" + + UPDATE = "update" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up update platform.""" + + coordinator = entry.runtime_data + async_add_entities( + [UptimeKumaUpdateEntity(coordinator, hass.data[UPTIME_KUMA_KEY])] + ) + + +class UptimeKumaUpdateEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], UpdateEntity +): + """Representation of an update entity.""" + + entity_description = UpdateEntityDescription( + key=UptimeKumaUpdate.UPDATE, + translation_key=UptimeKumaUpdate.UPDATE, + ) + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + update_coordinator: UptimeKumaSoftwareUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.update_checker = update_coordinator + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.config_entry.title, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{self.entity_description.key}" + ) + + @property + def installed_version(self) -> str | None: + """Current version.""" + + return self.coordinator.api.version.version + + @property + def title(self) -> str | None: + """Title of the release.""" + + return f"Uptime Kuma {self.update_checker.data.name}" + + @property + def release_url(self) -> str | None: + """URL to the full release notes.""" + + return self.update_checker.data.html_url + + @property + def latest_version(self) -> str | None: + """Latest version.""" + + return self.update_checker.data.tag_name + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + return self.update_checker.data.body + + async def async_added_to_hass(self) -> None: + """When entity is added to hass. + + Register extra update listener for the software update coordinator. + """ + await super().async_added_to_hass() + self.async_on_remove( + self.update_checker.async_add_listener(self._handle_coordinator_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.update_checker.last_update_success diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py index 4b7710a48b4..7895f068b31 100644 --- a/tests/components/uptime_kuma/conftest.py +++ b/tests/components/uptime_kuma/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion from pythonkuma.models import MonitorStatus +from pythonkuma.update import LatestRelease from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL @@ -99,3 +100,22 @@ def mock_pythonkuma() -> Generator[AsyncMock]: ) yield client + + +@pytest.fixture(autouse=True) +def mock_update_checker() -> Generator[AsyncMock]: + """Mock Update checker.""" + + with patch( + "homeassistant.components.uptime_kuma.UpdateChecker", + autospec=True, + ) as mock_client: + client = mock_client.return_value + client.latest_release.return_value = LatestRelease( + html_url="https://github.com/louislam/uptime-kuma/releases/tag/2.0.1", + name="2.0.1", + tag_name="2.0.1", + body="**RELEASE_NOTES**", + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_update.ambr b/tests/components/uptime_kuma/snapshots/test_update.ambr new file mode 100644 index 00000000000..225584a5181 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_update.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_update[update.uptime_example_org_uptime_kuma_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + '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': 'Uptime Kuma version', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': '123456789_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.uptime_example_org_uptime_kuma_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/uptime_kuma/icon.png', + 'friendly_name': 'uptime.example.org Uptime Kuma version', + 'in_progress': False, + 'installed_version': '2.0.0', + 'latest_version': '2.0.1', + 'release_summary': None, + 'release_url': 'https://github.com/louislam/uptime-kuma/releases/tag/2.0.1', + 'skipped_version': None, + 'supported_features': , + 'title': 'Uptime Kuma 2.0.1', + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.uptime_example_org_uptime_kuma_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/uptime_kuma/test_update.py b/tests/components/uptime_kuma/test_update.py new file mode 100644 index 00000000000..38d58b979a1 --- /dev/null +++ b/tests/components/uptime_kuma/test_update.py @@ -0,0 +1,77 @@ +"""Test the Uptime Kuma update platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import UpdateException +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def update_only() -> AsyncGenerator[None]: + """Enable only the update platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.UPDATE], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the update platform.""" + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": "update.uptime_example_org_uptime_kuma_version", + } + ) + result = await ws_client.receive_json() + assert result["result"] == "**RELEASE_NOTES**" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_update_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_update_checker: AsyncMock, +) -> None: + """Test update entity unavailable on error.""" + + mock_update_checker.latest_release.side_effect = UpdateException + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("update.uptime_example_org_uptime_kuma_version") + assert state is not None + assert state.state == STATE_UNAVAILABLE From 4ea7ad52b176304d11852d66525d6f12bac1823d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:09:13 +0200 Subject: [PATCH 0646/1118] Bump habiticalib to v0.4.1 (#149523) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 8b03e5efe01..d890ed23676 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.0"] + "requirements": ["habiticalib==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f873d3a9be..16978a32045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==4.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6c85395d5b..6a4becdab6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -985,7 +985,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==4.0.1 From a33760bc1a3e9be00865727285c8a8c107dabeab Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Sun, 27 Jul 2025 19:18:00 +0200 Subject: [PATCH 0647/1118] Update slixmpp to 1.10.0 (#149374) --- homeassistant/components/xmpp/manifest.json | 2 +- homeassistant/components/xmpp/notify.py | 5 +++-- requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index d77d70ff86c..d128e3e5111 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], "quality_scale": "legacy", - "requirements": ["slixmpp==1.8.5", "emoji==2.8.0"] + "requirements": ["slixmpp==1.10.0", "emoji==2.8.0"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 6ad0c1671a9..59ff3f584b4 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -150,7 +150,8 @@ async def async_send_message( # noqa: C901 self.loop = hass.loop - self.force_starttls = use_tls + self.enable_starttls = use_tls + self.enable_direct_tls = use_tls self.use_ipv6 = False self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) @@ -169,7 +170,7 @@ async def async_send_message( # noqa: C901 self.register_plugin("xep_0128") # Service Discovery self.register_plugin("xep_0363") # HTTP upload - self.connect(force_starttls=self.force_starttls, use_ssl=False) + self.connect() async def start(self, event): """Start the communication and sends the message.""" diff --git a/requirements_all.txt b/requirements_all.txt index 16978a32045..6a9f883dc26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2793,7 +2793,7 @@ skyboxremote==0.0.6 slack_sdk==3.33.4 # homeassistant.components.xmpp -slixmpp==1.8.5 +slixmpp==1.10.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 From ea2b3b3ff3bfaa55161013a64f315493deea64ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:22:01 +0200 Subject: [PATCH 0648/1118] Update ical + gcal-sync (#149413) --- homeassistant/components/google/calendar.py | 8 ++++---- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/calendar.py | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/calendar.py | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 6fef46395e8..d6d740bd0aa 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -230,7 +230,7 @@ async def async_setup_entry( calendar_info = calendars[calendar_id] else: calendar_info = get_calendar_info( - hass, calendar_item.dict(exclude_unset=True) + hass, calendar_item.model_dump(exclude_unset=True) ) new_calendars.append(calendar_info) @@ -467,7 +467,7 @@ class GoogleCalendarEntity( else: start = DateOrDatetime(date=dtstart) end = DateOrDatetime(date=dtend) - event = Event.parse_obj( + event = Event.model_validate( { EVENT_SUMMARY: kwargs[EVENT_SUMMARY], "start": start, @@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> if EVENT_IN in call.data: if EVENT_IN_DAYS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) end_in = start_in + timedelta(days=1) @@ -547,7 +547,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> end = DateOrDatetime(date=end_in) elif EVENT_IN_WEEKS in call.data[EVENT_IN]: - now = datetime.now() + now = datetime.now().date() start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) end_in = start_in + timedelta(days=1) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 1acfa3a2ad1..b15372b1555 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.1.0", "oauth2client==4.1.3", "ical==10.0.4"] + "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"] } diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index c8f906c6d54..3b6d6070f5a 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -221,7 +221,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: end = start + timedelta(days=1) return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=start, end=end, description=event.description, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 3bf00f30624..ffe4d379ce5 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 134cea5293b..48aa3032e73 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index f6918ea9706..7009a8af360 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -98,7 +98,7 @@ def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" return CalendarEvent( - summary=event.summary, + summary=event.summary or "", start=( dt_util.as_local(event.start) if isinstance(event.start, datetime) diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 6ba1dea55ed..b4e2d186add 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==10.0.4"] + "requirements": ["ical==11.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6a9f883dc26..dbae7e9b1a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -995,7 +995,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1210,7 +1210,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a4becdab6c..ab10c633944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -865,7 +865,7 @@ gardena-bluetooth==1.6.0 gassist-text==0.0.14 # homeassistant.components.google -gcal-sync==7.1.0 +gcal-sync==8.0.0 # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -1050,7 +1050,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.4 +ical==11.0.0 # homeassistant.components.caldav icalendar==6.1.0 From 5b08724d81a74778d27fae6d5a9839f3789b49ed Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 11 Jul 2025 23:45:57 +0200 Subject: [PATCH 0649/1118] Keep entities of dead Z-Wave devices available (#148611) --- homeassistant/components/zwave_js/entity.py | 22 +---------- homeassistant/components/zwave_js/update.py | 19 ++++------ tests/components/zwave_js/test_init.py | 42 ++++++++++++++++++++- tests/components/zwave_js/test_lock.py | 5 ++- tests/components/zwave_js/test_update.py | 14 +------ 5 files changed, 54 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d1ab9009308..08a587d8d20 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Sequence from typing import Any -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import ( @@ -27,8 +26,6 @@ from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id EVENT_VALUE_REMOVED = "value removed" -EVENT_DEAD = "dead" -EVENT_ALIVE = "alive" class ZWaveBaseEntity(Entity): @@ -141,11 +138,6 @@ class ZWaveBaseEntity(Entity): ) ) - for status_event in (EVENT_ALIVE, EVENT_DEAD): - self.async_on_remove( - self.info.node.on(status_event, self._node_status_alive_or_dead) - ) - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -211,19 +203,7 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return ( - self.driver.client.connected - and bool(self.info.node.ready) - and self.info.node.status != NodeStatus.DEAD - ) - - @callback - def _node_status_alive_or_dead(self, event_data: dict) -> None: - """Call when node status changes to alive or dead. - - Should not be overridden by subclasses. - """ - self.async_write_ha_state() + return self.driver.client.connected and bool(self.info.node.ready) @callback def _value_changed(self, event_data: dict) -> None: diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 985c4a86813..bf32754f50d 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -200,18 +200,13 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) return - # If device is asleep/dead, wait for it to wake up/become alive before - # attempting an update - for status, event_name in ( - (NodeStatus.ASLEEP, "wake up"), - (NodeStatus.DEAD, "alive"), - ): - if self.node.status == status: - if not self._status_unsub: - self._status_unsub = self.node.once( - event_name, self._update_on_status_change - ) - return + # If device is asleep, wait for it to wake up before attempting an update + if self.node.status == NodeStatus.ASLEEP: + if not self._status_unsub: + self._status_unsub = self.node.once( + "wake up", self._update_on_status_change + ) + return try: # Retrieve all firmware updates including non-stable ones but filter diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4350d7f7649..324a0f14941 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -37,7 +37,11 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY +from .common import ( + AIR_TEMPERATURE_SENSOR, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + EATON_RF9640_ENTITY, +) from tests.common import ( MockConfigEntry, @@ -2168,3 +2172,39 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_entity_available_when_node_dead( + hass: HomeAssistant, client, bulb_6_multi_color, integration +) -> None: + """Test that entities remain available even when the node is dead.""" + + node = bulb_6_multi_color + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state != STATE_UNAVAILABLE + + # Send dead event to the node + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should remain available even though the node is dead + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE + + # Send alive event to bring the node back + event = Event( + "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should still be available + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 1011026ac68..9e36810872f 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -28,7 +28,7 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -295,7 +295,8 @@ async def test_door_lock( assert node.status == NodeStatus.DEAD state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_UNAVAILABLE + # The state should still be locked, even if the node is dead + assert state.state == LockState.LOCKED async def test_only_one_lock( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fc225d529a6..17f154f4f78 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -277,7 +277,7 @@ async def test_update_entity_dead( zen_31, integration, ) -> None: - """Test update occurs when device is dead after it becomes alive.""" + """Test update occurs even when device is dead.""" event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": zen_31.node_id}, @@ -290,17 +290,7 @@ async def test_update_entity_dead( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 - - event = Event( - "alive", - data={"source": "node", "event": "alive", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the node is up we can check for updates + # Checking for firmware updates should proceed even for dead nodes assert len(client.async_send_command.call_args_list) > 0 args = client.async_send_command.call_args_list[0][0][0] From 254ccca4e511e9e5aa8a71c0445fce285cf8cbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Wed, 23 Jul 2025 17:05:35 +0200 Subject: [PATCH 0650/1118] Fix warning about failure to get action during setup phase (#148923) --- homeassistant/components/wmspro/button.py | 2 +- homeassistant/components/wmspro/cover.py | 4 ++-- homeassistant/components/wmspro/light.py | 4 ++-- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wmspro/button.py b/homeassistant/components/wmspro/button.py index f1ab0489b86..1b2772a9c80 100644 --- a/homeassistant/components/wmspro/button.py +++ b/homeassistant/components/wmspro/button.py @@ -23,7 +23,7 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [ WebControlProIdentifyButton(config_entry.entry_id, dest) for dest in hub.dests.values() - if dest.action(WMS_WebControl_pro_API_actionDescription.Identify) + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.Identify) ] async_add_entities(entities) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index b6f100280ad..e7255d478cb 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -32,9 +32,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.action( + elif dest.hasAction( WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive ): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 52d092ed9f0..2326734ceaf 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -33,9 +33,9 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.action(WMS_WebControl_pro_API_actionDescription.LightDimming): + if dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightDimming): entities.append(WebControlProDimmer(config_entry.entry_id, dest)) - elif dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch): + elif dest.hasAction(WMS_WebControl_pro_API_actionDescription.LightSwitch): entities.append(WebControlProLight(config_entry.entry_id, dest)) async_add_entities(entities) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 9185768165a..9dbcf09a7d4 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.0"] + "requirements": ["pywmspro==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c9f708ad9ab..56a1e7f8597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2596,7 +2596,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e0ad947c38..bac418c6d59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2154,7 +2154,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.0 +pywmspro==0.3.2 # homeassistant.components.ws66i pyws66i==1.1 From 959c3a8a996ad485332752ecb5ccb4c90b8b2696 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 19 Jul 2025 10:49:14 -0700 Subject: [PATCH 0651/1118] Fix a bug in rainbird device migration that results in additional devices (#149078) --- homeassistant/components/rainbird/__init__.py | 3 + tests/components/rainbird/test_init.py | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f9cd751a81e..e986cc302ae 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -218,6 +218,9 @@ def _async_fix_device_id( for device_entry in device_entries: unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry + if unique_id.startswith(mac_address): + # Already in the correct format + continue if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 01e0c4458e4..520f8578c6e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -449,3 +449,75 @@ async def test_fix_duplicate_device_ids( assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} assert device_entry.name_by_user == expected_device_name assert device_entry.disabled_by == expected_disabled_by + + +async def test_reload_migration_with_leading_zero_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration and reload of a device with a mac address with a leading zero.""" + mac_address = "01:02:03:04:05:06" + mac_address_unique_id = dr.format_mac(mac_address) + serial_number = "0" + + # Setup the config entry to be in a pre-migrated state + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + data={ + "host": "127.0.0.1", + "password": "password", + CONF_MAC: mac_address, + "serial_number": serial_number, + }, + ) + config_entry.add_to_hass(hass) + + # Create a device and entity with the old unique id format + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{serial_number}-1")}, + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{serial_number}-1-zone1", + suggested_object_id="zone1", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Setup the integration, which will migrate the unique ids + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity were migrated to the new format + migrated_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mac_address_unique_id}-1")} + ) + assert migrated_device_entry is not None + migrated_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert migrated_entity_entry is not None + assert migrated_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + # Reload the integration + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity still have the correct identifiers and were not duplicated + reloaded_device_entry = device_registry.async_get(migrated_device_entry.id) + assert reloaded_device_entry is not None + assert reloaded_device_entry.identifiers == {(DOMAIN, f"{mac_address_unique_id}-1")} + reloaded_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert reloaded_entity_entry is not None + assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) From fa207860a0582a54a44eb94d9d617402f4845257 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 22 Jul 2025 13:42:40 +0800 Subject: [PATCH 0652/1118] Fix multiple webhook secrets for Telegram bot (#149103) --- homeassistant/components/telegram_bot/webhooks.py | 14 ++++++++++---- tests/components/telegram_bot/test_telegram_bot.py | 12 ++++++------ tests/components/telegram_bot/test_webhooks.py | 5 +++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 0bfad34681a..29c3305858b 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -82,7 +82,7 @@ class PushBot(BaseTelegramBot): self.base_url = config.data.get(CONF_URL) or get_url( hass, require_ssl=True, allow_internal=False ) - self.webhook_url = f"{self.base_url}{TELEGRAM_WEBHOOK_URL}" + self.webhook_url = self.base_url + _get_webhook_url(bot) async def shutdown(self) -> None: """Shutdown the app.""" @@ -98,9 +98,11 @@ class PushBot(BaseTelegramBot): api_kwargs={"secret_token": self.secret_token}, connect_timeout=5, ) - except TelegramError: + except TelegramError as err: retry_num += 1 - _LOGGER.warning("Error trying to set webhook (retry #%d)", retry_num) + _LOGGER.warning( + "Error trying to set webhook (retry #%d)", retry_num, exc_info=err + ) return False @@ -143,7 +145,6 @@ class PushBotView(HomeAssistantView): """View for handling webhook calls from Telegram.""" requires_auth = False - url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" def __init__( @@ -160,6 +161,7 @@ class PushBotView(HomeAssistantView): self.application = application self.trusted_networks = trusted_networks self.secret_token = secret_token + self.url = _get_webhook_url(bot) async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" @@ -183,3 +185,7 @@ class PushBotView(HomeAssistantView): await self.application.process_update(update) return None + + +def _get_webhook_url(bot: Bot) -> str: + return f"{TELEGRAM_WEBHOOK_URL}_{bot.id}" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 73dd9e27763..80b9859ceab 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -364,7 +364,7 @@ async def test_webhook_endpoint_generates_telegram_text_event( events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -391,7 +391,7 @@ async def test_webhook_endpoint_generates_telegram_command_event( events = async_capture_events(hass, "telegram_command") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_command, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -418,7 +418,7 @@ async def test_webhook_endpoint_generates_telegram_callback_event( events = async_capture_events(hass, "telegram_callback") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_callback_query, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -594,7 +594,7 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=unauthorized_update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) @@ -618,7 +618,7 @@ async def test_webhook_endpoint_without_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, ) assert response.status == 401 @@ -636,7 +636,7 @@ async def test_webhook_endpoint_invalid_secret_token_is_denied( async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, + f"{TELEGRAM_WEBHOOK_URL}_123456", json=update_message_text, headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, ) diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index 3419d33074d..a02bb3e3358 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from telegram import WebhookInfo from telegram.error import TimedOut +from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -115,7 +116,7 @@ async def test_webhooks_update_invalid_json( client = await hass_client() response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 400 @@ -139,7 +140,7 @@ async def test_webhooks_unauthorized_network( return_value=IPv4Network("1.2.3.4"), ) as mock_remote: response = await client.post( - "/api/telegram_webhooks", + f"{TELEGRAM_WEBHOOK_URL}_123456", json="mock json", headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) From f428ffde872e4fb078479579a985c17520139da7 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 01:25:45 -0400 Subject: [PATCH 0653/1118] Bump pyschlage to 2025.7.2 (#149148) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 893c30dfd41..c5b91cefd2e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.4.0"] + "requirements": ["pyschlage==2025.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56a1e7f8597..0b6fa6d9685 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2309,7 +2309,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bac418c6d59..0917f77dc27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1921,7 +1921,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 From c3eb6dea1132f9957a454b8291863cbe50ee8142 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Fri, 25 Jul 2025 13:09:58 -0400 Subject: [PATCH 0654/1118] Fix Matter light get brightness (#149186) --- homeassistant/components/matter/light.py | 7 ++++++- tests/components/matter/test_light.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c61fd0879fa..a86938730c9 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models import device_types from homeassistant.components.light import ( @@ -241,7 +242,7 @@ class MatterLight(MatterEntity, LightEntity): return int(color_temp) - def _get_brightness(self) -> int: + def _get_brightness(self) -> int | None: """Get brightness from matter.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -255,6 +256,10 @@ class MatterLight(MatterEntity, LightEntity): self.entity_id, ) + if level_control.currentLevel is NullValue: + # currentLevel is a nullable value. + return None + return round( renormalize( level_control.currentLevel, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index b600ededa6e..f9abf986170 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -131,6 +131,15 @@ async def test_dimmable_light( ) -> None: """Test a dimmable light.""" + # Test for currentLevel is None + set_node_attribute(matter_node, 1, 8, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] is None + # Test that the light brightness is 50 (out of 254) set_node_attribute(matter_node, 1, 8, 0, 50) await trigger_subscription_callback(hass, matter_client) From 68b7d094760520819033cc982ba17d4f89232e81 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 24 Jul 2025 00:55:44 +1000 Subject: [PATCH 0655/1118] Fix brightness_step and brightness_step_pct via lifx.set_state (#149217) Signed-off-by: Avi Miller --- homeassistant/components/lifx/light.py | 17 ++++++++++ tests/components/lifx/test_light.py | 44 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 3d30fcd369e..7a1b51ac8ae 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -10,6 +10,9 @@ import aiolifx_effects as aiolifx_effects_module import voluptuous as vol from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_EFFECT, ATTR_TRANSITION, LIGHT_TURN_ON_SCHEMA, @@ -234,6 +237,20 @@ class LIFXLight(LIFXEntity, LightEntity): else: fade = 0 + if ATTR_BRIGHTNESS_STEP in kwargs or ATTR_BRIGHTNESS_STEP_PCT in kwargs: + brightness = self.brightness if self.is_on and self.brightness else 0 + + if ATTR_BRIGHTNESS_STEP in kwargs: + brightness += kwargs.pop(ATTR_BRIGHTNESS_STEP) + + else: + brightness_pct = round(brightness / 255 * 100) + brightness = round( + (brightness_pct + kwargs.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255 + ) + + kwargs[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + # These are both False if ATTR_POWER is not set power_on = kwargs.get(ATTR_POWER, False) power_off = not kwargs.get(ATTR_POWER, True) diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index d66908c1b1a..edb13c259e8 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -30,6 +30,8 @@ from homeassistant.components.lifx.manager import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, ATTR_COLOR_MODE, ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, @@ -1735,6 +1737,48 @@ async def test_transitions_color_bulb(hass: HomeAssistant) -> None: bulb.set_color.reset_mock() +async def test_lifx_set_state_brightness(hass: HomeAssistant) -> None: + """Test lifx.set_state works with brightness, brightness_pct and brightness_step.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb_new_firmware() + bulb.power_level = 65535 + bulb.color = [0, 0, 32768, 3500] + with ( + _patch_discovery(device=bulb), + _patch_config_flow_try_connect(device=bulb), + _patch_device(device=bulb), + ): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + # brightness_step should convert from 8 bit to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP: 128}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + # brightness_step_pct should convert from percentage to 16 bit + await hass.services.async_call( + DOMAIN, + "set_state", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_STEP_PCT: 50}, + blocking=True, + ) + + assert bulb.set_color.calls[0][0][0] == [0, 0, 65535, 3500] + bulb.set_color.reset_mock() + + async def test_lifx_set_state_color(hass: HomeAssistant) -> None: """Test lifx.set_state works with color names and RGB.""" config_entry = MockConfigEntry( From 60f4d29d608d4f83cdaa9d202a1e1439841267bb Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 22 Jul 2025 16:09:11 +0200 Subject: [PATCH 0656/1118] Add Z-Wave USB migration confirm step (#149243) --- homeassistant/components/zwave_js/config_flow.py | 15 ++++++++++++++- homeassistant/components/zwave_js/strings.json | 4 ++++ tests/components/zwave_js/test_config_flow.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 7e95e274713..925946ed7f1 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -495,10 +495,23 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._usb_discovery = True if current_config_entries: - return await self.async_step_intent_migrate() + return await self.async_step_confirm_usb_migration() return await self.async_step_installation_type() + async def async_step_confirm_usb_migration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm USB migration.""" + if user_input is not None: + return await self.async_step_intent_migrate() + return self.async_show_form( + step_id="confirm_usb_migration", + description_placeholders={ + "usb_title": self.context["title_placeholders"][CONF_NAME], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 7445182e5f6..835dfbf7471 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,6 +108,10 @@ "start_addon": { "title": "Configuring add-on" }, + "confirm_usb_migration": { + "description": "You are about to migrate your Z-Wave network from the old adapter to the new adapter {usb_title}. This will take a backup of the network from the old adapter and restore the network to the new adapter.\n\nPress Submit to continue with the migration.", + "title": "Migrate to a new adapter" + }, "zeroconf_confirm": { "description": "Do you want to add the Z-Wave Server with home ID {home_id} found at {url} to Home Assistant?", "title": "Discovered Z-Wave Server" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a1642746d03..c708b1c9d66 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -932,6 +932,11 @@ async def test_usb_discovery_migration( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -1049,6 +1054,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert mock_usb_serial_by_id.call_count == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_usb_migration" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" From 4c8ab8eb6402bcfe896aa7ca2c84e7c6aec0aeb4 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Wed, 23 Jul 2025 08:52:14 -0400 Subject: [PATCH 0657/1118] Add fan off mode to the supported fan modes to fujitsu_fglair (#149277) --- homeassistant/components/fujitsu_fglair/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index bf1df07823c..85ef119a583 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( FAN_HIGH, FAN_LOW, FAN_MEDIUM, + FAN_OFF, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -31,6 +32,7 @@ from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { + FAN_OFF: FanSpeed.QUIET, FAN_LOW: FanSpeed.LOW, FAN_MEDIUM: FanSpeed.MEDIUM, FAN_HIGH: FanSpeed.HIGH, From cd800da357db61799ec66120e5805d1e7d6f1b51 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 23 Jul 2025 22:45:50 +1000 Subject: [PATCH 0658/1118] Update Tesla OAuth Server in Tesla Fleet (#149280) --- homeassistant/components/tesla_fleet/const.py | 5 ++--- tests/components/tesla_fleet/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index d73234b1fdd..761bbebf7a8 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -14,9 +14,8 @@ CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) -CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d" -AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize" -TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token" +AUTHORIZE_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize" +TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" SCOPES = [ Scope.OPENID, diff --git a/tests/components/tesla_fleet/__init__.py b/tests/components/tesla_fleet/__init__.py index c51cd83ee66..a43ec14fc51 100644 --- a/tests/components/tesla_fleet/__init__.py +++ b/tests/components/tesla_fleet/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.tesla_fleet.const import CLIENT_ID, DOMAIN +from homeassistant.components.tesla_fleet.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,7 +28,7 @@ async def setup_platform( await async_import_client_credential( hass, DOMAIN, - ClientCredential(CLIENT_ID, "", "Home Assistant"), + ClientCredential("CLIENT_ID", "CLIENT_SECRET", "Home Assistant"), DOMAIN, ) From 4a7d06a68a44546d3277e207158cac5c0bad4bc4 Mon Sep 17 00:00:00 2001 From: Alex Hermann Date: Sun, 27 Jul 2025 19:18:00 +0200 Subject: [PATCH 0659/1118] Update slixmpp to 1.10.0 (#149374) --- homeassistant/components/xmpp/manifest.json | 2 +- homeassistant/components/xmpp/notify.py | 5 +++-- requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json index d77d70ff86c..d128e3e5111 100644 --- a/homeassistant/components/xmpp/manifest.json +++ b/homeassistant/components/xmpp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["pyasn1", "slixmpp"], "quality_scale": "legacy", - "requirements": ["slixmpp==1.8.5", "emoji==2.8.0"] + "requirements": ["slixmpp==1.10.0", "emoji==2.8.0"] } diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 968f925d1e8..c9829746d59 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -144,7 +144,8 @@ async def async_send_message( # noqa: C901 self.loop = hass.loop - self.force_starttls = use_tls + self.enable_starttls = use_tls + self.enable_direct_tls = use_tls self.use_ipv6 = False self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) @@ -163,7 +164,7 @@ async def async_send_message( # noqa: C901 self.register_plugin("xep_0128") # Service Discovery self.register_plugin("xep_0363") # HTTP upload - self.connect(force_starttls=self.force_starttls, use_ssl=False) + self.connect() async def start(self, event): """Start the communication and sends the message.""" diff --git a/requirements_all.txt b/requirements_all.txt index 0b6fa6d9685..dbc6d309c15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2786,7 +2786,7 @@ skyboxremote==0.0.6 slack_sdk==3.33.4 # homeassistant.components.xmpp -slixmpp==1.8.5 +slixmpp==1.10.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 From dc6d2e3e8462f838435fc7ce3c83b78099e7fa23 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 24 Jul 2025 18:54:00 +0300 Subject: [PATCH 0660/1118] Bump aioamazondevices to 3.5.1 (#149385) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 6904f8000ce..c1eb3369c11 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.5.0"] + "requirements": ["aioamazondevices==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbc6d309c15..78aa0cc4f6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0917f77dc27..f4c0f06175f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.0 +aioamazondevices==3.5.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 725799c73ef46a58ef0217b788d3a29ede0a10a6 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Sat, 26 Jul 2025 12:38:38 +0200 Subject: [PATCH 0661/1118] Bump pysuezV2 to 2.0.7 (#149436) --- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/suez_water/conftest.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 9149f216563..5c23240ce91 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.5"] + "requirements": ["pysuezV2==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78aa0cc4f6c..be200069c50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4c0f06175f..e978a39f58b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1987,7 +1987,7 @@ pysqueezebox==0.12.1 pystiebeleltron==0.1.0 # homeassistant.components.suez_water -pysuezV2==2.0.5 +pysuezV2==2.0.7 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 9d29191289e..005c14b7458 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -87,5 +87,7 @@ def mock_suez_client(recorder_mock: Recorder) -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result - suez_client.get_price.return_value = PriceResult("4.74") + suez_client.get_price.return_value = PriceResult( + "OK", {"price": 4.74}, "Price is 4.74" + ) yield suez_client From f0cb5d54808e950f8982efb5f0d79b6e9e9a0a7d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 19:09:13 +0200 Subject: [PATCH 0662/1118] Bump habiticalib to v0.4.1 (#149523) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 8b03e5efe01..d890ed23676 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.0"] + "requirements": ["habiticalib==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index be200069c50..6eb24ec184b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==3.49.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e978a39f58b..ebd3f9e863b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.0 +habiticalib==0.4.1 # homeassistant.components.bluetooth habluetooth==3.49.0 From d384bee5767f248f383a2fa0f1bb1681ff32a2c2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 27 Jul 2025 17:35:19 +0000 Subject: [PATCH 0663/1118] Bump version to 2025.7.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 62e6d49befd..6db2d238a98 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c010ecb7254..03ad9af59a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.3" +version = "2025.7.4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From ff4dc393cf09dbe062da2e86d740bceeef14df1e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 27 Jul 2025 20:00:50 +0200 Subject: [PATCH 0664/1118] Bump reolink-aio to 0.14.4 (#149521) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f8b8191a851..39541476429 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.3"] + "requirements": ["reolink-aio==0.14.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbae7e9b1a3..80638d4a596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.3 +reolink-aio==0.14.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab10c633944..895aca708be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2209,7 +2209,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.3 +reolink-aio==0.14.4 # homeassistant.components.rflink rflink==0.0.67 From c99d81a554ed7aa08379dfcdb8d34726ac32480e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:02:24 +0200 Subject: [PATCH 0665/1118] Set PARALLEL_UPDATES in Tankerkoenig platforms (#149518) --- homeassistant/components/tankerkoenig/binary_sensor.py | 3 +++ homeassistant/components/tankerkoenig/sensor.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index a38266e57e8..d571dfe99d2 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -17,6 +17,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index b1646489d96..82c89f90fe4 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -24,6 +24,9 @@ from .const import ( from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) From 431b2aa1d520da6a7d94b39ea48f2f139170c258 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:13:05 +0200 Subject: [PATCH 0666/1118] Add data description strings to Tankerkoenig (#149519) Co-authored-by: Josef Zweck Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/tankerkoenig/strings.json | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index db620b2b11c..3f821c7c6fa 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -1,4 +1,11 @@ { + "common": { + "data_description_api_key": "The tankerkoenig API key to be used.", + "data_description_location": "Pick the location where to search for gas stations.", + "data_description_name": "The name of the particular region to be added.", + "data_description_radius": "The radius in kilometers to search for gas stations around the selected location.", + "data_description_stations": "Select the stations you want to add to Home Assistant." + }, "config": { "step": { "user": { @@ -6,13 +13,21 @@ "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]", - "stations": "Additional fuel stations", "radius": "Search radius" + }, + "data_description": { + "name": "[%key:component::tankerkoenig::common::data_description_name%]", + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]", + "location": "[%key:component::tankerkoenig::common::data_description_location%]", + "radius": "[%key:component::tankerkoenig::common::data_description_radius%]" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::tankerkoenig::common::data_description_api_key%]" } }, "select_station": { @@ -20,6 +35,9 @@ "description": "Found {stations_count} stations in radius", "data": { "stations": "Stations" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]" } } }, @@ -39,6 +57,10 @@ "data": { "stations": "[%key:component::tankerkoenig::config::step::select_station::data::stations%]", "show_on_map": "Show stations on map" + }, + "data_description": { + "stations": "[%key:component::tankerkoenig::common::data_description_stations%]", + "show_on_map": "Whether to show the station sensors on the map or not." } } }, From dbb573038966ed8ec43904afb7f0b653d9be8bdb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 27 Jul 2025 20:35:01 +0200 Subject: [PATCH 0667/1118] Increase trophy titles retrieval page size to 500 for PlayStation Network (#149528) --- homeassistant/components/playstation_network/coordinator.py | 2 +- homeassistant/components/playstation_network/helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index a9f49f7f7bb..fa00ac2c8ec 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -116,7 +116,7 @@ class PlaystationNetworkTrophyTitlesCoordinator( async def update_data(self) -> list[TrophyTitle]: """Update trophy titles data.""" self.psn.trophy_titles = await self.hass.async_add_executor_job( - lambda: list(self.psn.user.trophy_titles()) + lambda: list(self.psn.user.trophy_titles(page_size=500)) ) await self.config_entry.runtime_data.user_data.async_request_refresh() return self.psn.trophy_titles diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index f7f6143e94f..358e1c13025 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -67,7 +67,7 @@ class PlaystationNetwork: self.user = self.psn.user(online_id="me") self.client = self.psn.me() self.shareable_profile_link = self.client.get_shareable_profile_link() - self.trophy_titles = list(self.user.trophy_titles()) + self.trophy_titles = list(self.user.trophy_titles(page_size=500)) async def async_setup(self) -> None: """Setup PSN.""" From a060f7486f1ccf1f49da59f8aae3745ebcdd6655 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 27 Jul 2025 20:36:25 +0200 Subject: [PATCH 0668/1118] Replace duplicated strings and fix "street name" in `waze_travel_time` (#149512) --- .../components/waze_travel_time/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 8f8de694b2d..c57f5470b04 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -27,8 +27,8 @@ "data": { "units": "Units", "vehicle_type": "Vehicle type", - "incl_filter": "Exact streetname which must be part of the selected route", - "excl_filter": "Exact streetname which must NOT be part of the selected route", + "incl_filter": "Exact street name which must be part of the selected route", + "excl_filter": "Exact street name which must NOT be part of the selected route", "realtime": "Realtime travel time?", "avoid_toll_roads": "Avoid toll roads?", "avoid_ferries": "Avoid ferries?", @@ -103,12 +103,12 @@ "description": "Whether to avoid subscription roads." }, "incl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]", - "description": "Exact streetname which must be part of the selected route." + "name": "Streets to include", + "description": "[%key:component::waze_travel_time::options::step::init::data::incl_filter%]" }, "excl_filter": { - "name": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]", - "description": "Exact streetname which must NOT be part of the selected route." + "name": "Streets to exclude", + "description": "[%key:component::waze_travel_time::options::step::init::data::excl_filter%]" } } } From 1fa9141ce102796cc7abdd54f886431f2289c50b Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 27 Jul 2025 21:52:53 +0200 Subject: [PATCH 0669/1118] Bump uiprotect to version 7.20.0 (#149533) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2f79154e0c5..8eee080abb4 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.19.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.20.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 80638d4a596..6f89bdf8d8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3001,7 +3001,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.19.0 +uiprotect==7.20.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 895aca708be..68b6b38c77e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2475,7 +2475,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.19.0 +uiprotect==7.20.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 622cce03a1418c01c1e78b646b09d41282ecac11 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:46:59 +0200 Subject: [PATCH 0670/1118] Bump aioautomower to 2.1.0 (#149541) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 798bd631e43..f5de5a3dff8 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.0.2"] + "requirements": ["aioautomower==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f89bdf8d8a..7c1e7058f0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.2 +aioautomower==2.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68b6b38c77e..b1a8dee354f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.0.2 +aioautomower==2.1.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From e30d40562527e5d3c3fe88bf1421518e334d9446 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 27 Jul 2025 22:48:15 +0200 Subject: [PATCH 0671/1118] Enable strict typing in Tankerkoenig (#149535) --- .strict-typing | 1 + homeassistant/components/tankerkoenig/config_flow.py | 4 ++-- homeassistant/components/tankerkoenig/sensor.py | 11 +++++++++-- mypy.ini | 10 ++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 18e72162a23..3f87bfa18e8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -501,6 +501,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tailwind.* homeassistant.components.tami4.* +homeassistant.components.tankerkoenig.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.technove.* diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 9aeb0a80173..6207c7261b0 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -15,7 +15,6 @@ from aiotankerkoenig import ( import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -40,6 +39,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN +from .coordinator import TankerkoenigConfigEntry async def async_get_nearby_stations( @@ -71,7 +71,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: TankerkoenigConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 82c89f90fe4..9964a300d6f 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -110,7 +110,14 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): self._attr_extra_state_attributes = attrs @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the current price for the fuel type.""" info = self.coordinator.data[self._station_id] - return getattr(info, self._fuel_type) + result = None + if self._fuel_type is GasType.E10: + result = info.e10 + elif self._fuel_type is GasType.E5: + result = info.e5 + else: + result = info.diesel + return result diff --git a/mypy.ini b/mypy.ini index bff6c93967e..bfd9cfb0a84 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4768,6 +4768,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tankerkoenig.*] +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.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true From f35558413acb512c572493cf6b6bce28f073dd59 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 28 Jul 2025 15:58:59 +1000 Subject: [PATCH 0672/1118] Bump tesla-fleet-api to 1.2.3 (#149550) --- 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 cf86fbeb4f9..3420ed9f46e 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.2.2"] + "requirements": ["tesla-fleet-api==1.2.3"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index d12cf278d59..b6aff150a96 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.2.2", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.3", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 26f26990d58..e2ebf64f241 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.2.2"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c1e7058f0c..dbdc82ac06a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2911,7 +2911,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.2 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1a8dee354f..976318e7733 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2397,7 +2397,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.2 +tesla-fleet-api==1.2.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From ab6cd0eb41c40f5f1b7321b683a45c4cffefc24a Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 28 Jul 2025 09:42:40 +0300 Subject: [PATCH 0673/1118] Bump israel-rail to 0.1.3 (#149555) --- homeassistant/components/israel_rail/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json index afe085f5729..33e4219bbac 100644 --- a/homeassistant/components/israel_rail/manifest.json +++ b/homeassistant/components/israel_rail/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/israel_rail", "iot_class": "cloud_polling", "loggers": ["israelrailapi"], - "requirements": ["israel-rail-api==0.1.2"] + "requirements": ["israel-rail-api==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbdc82ac06a..38770807246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1273,7 +1273,7 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 976318e7733..d5ee2f35922 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ isal==1.7.1 ismartgate==5.0.2 # homeassistant.components.israel_rail -israel-rail-api==0.1.2 +israel-rail-api==0.1.3 # homeassistant.components.abode jaraco.abode==6.2.1 From c67636b4f6114efce6c6745b7d97cac0d39aeb33 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 28 Jul 2025 11:35:52 +0300 Subject: [PATCH 0674/1118] Add support for EVs in `ituran` (#149484) --- .../components/ituran/device_tracker.py | 6 +- homeassistant/components/ituran/icons.json | 3 + homeassistant/components/ituran/sensor.py | 28 +- homeassistant/components/ituran/strings.json | 3 + tests/components/ituran/conftest.py | 16 +- .../ituran/snapshots/test_sensor.ambr | 411 ++++++++++++++++++ tests/components/ituran/test_sensor.py | 48 +- 7 files changed, 503 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 5f816709864..0656bdfa497 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -2,6 +2,8 @@ from __future__ import annotations +from propcache.api import cached_property + from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): """Initialize the device tracker.""" super().__init__(coordinator, license_plate, "device_tracker") - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of the device.""" return self.vehicle.gps_coordinates[0] - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of the device.""" return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json index bd9182f1569..0b721ca5001 100644 --- a/homeassistant/components/ituran/icons.json +++ b/homeassistant/components/ituran/icons.json @@ -9,6 +9,9 @@ "address": { "default": "mdi:map-marker" }, + "battery_range": { + "default": "mdi:ev-station" + }, "battery_voltage": { "default": "mdi:car-battery" }, diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index a115b2be89c..50e86b374a1 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from propcache.api import cached_property from pyituran import Vehicle from homeassistant.components.sensor import ( @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEGREE, + PERCENTAGE, UnitOfElectricPotential, UnitOfLength, UnitOfSpeed, @@ -33,6 +35,7 @@ class IturanSensorEntityDescription(SensorEntityDescription): """Describes Ituran sensor entity.""" value_fn: Callable[[Vehicle], StateType | datetime] + supported_fn: Callable[[Vehicle], bool] = lambda _: True SENSOR_TYPES: list[IturanSensorEntityDescription] = [ @@ -42,6 +45,22 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [ entity_registry_enabled_default=False, value_fn=lambda vehicle: vehicle.address, ), + IturanSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda vehicle: vehicle.battery_level, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), + IturanSensorEntityDescription( + key="battery_range", + translation_key="battery_range", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.battery_range, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), IturanSensorEntityDescription( key="battery_voltage", translation_key="battery_voltage", @@ -92,14 +111,15 @@ async def async_setup_entry( """Set up the Ituran sensors from config entry.""" coordinator = config_entry.runtime_data async_add_entities( - IturanSensor(coordinator, license_plate, description) + IturanSensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() for description in SENSOR_TYPES - for license_plate in coordinator.data + if description.supported_fn(vehicle) ) class IturanSensor(IturanBaseEntity, SensorEntity): - """Ituran device tracker.""" + """Ituran sensor.""" entity_description: IturanSensorEntityDescription @@ -113,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity): super().__init__(coordinator, license_plate, description.key) self.entity_description = description - @property + @cached_property def native_value(self) -> StateType | datetime: """Return the state of the device.""" return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json index efc60ef454b..ededb5232f5 100644 --- a/homeassistant/components/ituran/strings.json +++ b/homeassistant/components/ituran/strings.json @@ -40,6 +40,9 @@ "address": { "name": "Address" }, + "battery_range": { + "name": "Remaining range" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 5093cc301a1..1cb922b94e9 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -47,7 +47,7 @@ def mock_config_entry() -> MockConfigEntry: class MockVehicle: """Mock vehicle.""" - def __init__(self) -> None: + def __init__(self, is_electric_vehicle=False) -> None: """Initialize mock vehicle.""" self.license_plate = "12345678" self.make = "mock make" @@ -61,11 +61,18 @@ class MockVehicle: 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") ) self.battery_voltage = 12.0 + self.is_electric_vehicle = is_electric_vehicle + if is_electric_vehicle: + self.battery_level = 42 + self.battery_range = 150 + else: + self.battery_level = 0 + self.battery_range = 0 @pytest.fixture -def mock_ituran() -> Generator[AsyncMock]: - """Return a mocked PalazzettiClient.""" +def mock_ituran(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Return a mocked Ituran.""" with ( patch( "homeassistant.components.ituran.coordinator.Ituran", @@ -79,7 +86,8 @@ def mock_ituran() -> Generator[AsyncMock]: mock_ituran = ituran.return_value mock_ituran.is_authenticated.return_value = False mock_ituran.authenticate.return_value = True - mock_ituran.get_vehicles.return_value = [MockVehicle()] + is_electric_vehicle = getattr(request, "param", False) + mock_ituran.get_vehicles.return_value = [MockVehicle(is_electric_vehicle)] type(mock_ituran).mobile_id = PropertyMock( return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] ) diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index 5278c657a66..a577d836b0e 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -1,4 +1,415 @@ # serializer version: 1 +# name: test_ev_sensor[True][sensor.mock_model_address-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_model_address', + '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': 'Address', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Address', + }), + 'context': , + 'entity_id': 'sensor.mock_model_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bermuda Triangle', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-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_model_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': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'mock model Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-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_model_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'mock model Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-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_model_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heading', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_heading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Heading', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.mock_model_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-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_model_last_update_from_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update from vehicle', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'mock model Last update from vehicle', + }), + 'context': , + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T22:00:00+00:00', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-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_model_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Mileage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-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_model_remaining_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_range', + 'unique_id': '12345678-battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Remaining range', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_remaining_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_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_model_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'mock model Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- # name: test_sensor[sensor.mock_model_address-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py index a057f59b81f..4293cf08f2d 100644 --- a/tests/components/ituran/test_sensor.py +++ b/tests/components/ituran/test_sensor.py @@ -32,13 +32,27 @@ async def test_sensor( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_availability( +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def __test_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, + ev_entity_names: list[str] | None = None, ) -> None: - """Test sensor is marked as unavailable when we can't reach the Ituran service.""" entities = [ "sensor.mock_model_address", "sensor.mock_model_battery_voltage", @@ -46,6 +60,7 @@ async def test_availability( "sensor.mock_model_last_update_from_vehicle", "sensor.mock_model_mileage", "sensor.mock_model_speed", + *(ev_entity_names if ev_entity_names is not None else []), ] await setup_integration(hass, mock_config_entry) @@ -74,3 +89,32 @@ async def test_availability( state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ICE sensor is marked as unavailable when we can't reach the Ituran service.""" + await __test_availability(hass, freezer, mock_ituran, mock_config_entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test EV sensor is marked as unavailable when we can't reach the Ituran service.""" + ev_entities = [ + "sensor.mock_model_battery", + "sensor.mock_model_remaining_range", + ] + await __test_availability( + hass, freezer, mock_ituran, mock_config_entry, ev_entities + ) From 05935bbc01d3f5db0d5544b72f3bdd02ef075138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 28 Jul 2025 10:17:26 +0100 Subject: [PATCH 0675/1118] Bump hass-nabucasa from 0.108.0 to 0.110.0 (#149560) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 72748efff6e..a819203e549 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.108.0"], + "requirements": ["hass-nabucasa==0.110.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 88aa9418ddc..a43eadce0de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.3 diff --git a/pyproject.toml b/pyproject.toml index 162f63ff064..b75b80f47dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.108.0", + "hass-nabucasa==0.110.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 65d0309747e..6110854f5f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 38770807246..b2e14e4241c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5ee2f35922..21eab297f03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.108.0 +hass-nabucasa==0.110.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From a68e722c929498643df46c2275450db45ec6e8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 28 Jul 2025 11:33:03 +0200 Subject: [PATCH 0676/1118] Matter MicrowaveOven device (#148219) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/icons.json | 8 ++ homeassistant/components/matter/number.py | 62 ++++++++++++---- homeassistant/components/matter/select.py | 28 ++++++- homeassistant/components/matter/strings.json | 6 ++ .../matter/fixtures/nodes/microwave_oven.json | 2 + .../matter/snapshots/test_number.ambr | 59 +++++++++++++++ .../matter/snapshots/test_select.ambr | 73 +++++++++++++++++++ tests/components/matter/test_number.py | 33 +++++++++ tests/components/matter/test_select.py | 47 ++++++++++++ 9 files changed, 300 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 32f822414aa..2b9ca2cc3e2 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -40,6 +40,9 @@ "laundry_washer_spin_speed": { "default": "mdi:reload" }, + "power_level": { + "default": "mdi:power-settings" + }, "temperature_level": { "default": "mdi:thermometer" } @@ -115,6 +118,11 @@ "default": "mdi:pump" } }, + "number": { + "cook_time": { + "default": "mdi:microwave" + } + }, "switch": { "child_lock": { "default": "mdi:lock", diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index ea348c20012..4456496d52e 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand @@ -55,12 +55,16 @@ class MatterRangeNumberEntityDescription( ): """Describe Matter Number Input entities with min and max values.""" - ha_to_device: Callable[[Any], Any] + ha_to_device: Callable[[Any], Any] = lambda x: x # attribute descriptors to get the min and max value - min_attribute: type[ClusterAttributeDescriptor] + min_attribute: type[ClusterAttributeDescriptor] | None = None max_attribute: type[ClusterAttributeDescriptor] + # Functions to format the min and max values for display or conversion + format_min_value: Callable[[float], float] = lambda x: x + format_max_value: Callable[[float], float] = lambda x: x + # command: a custom callback to create the command to send to the device # the callback's argument will be the index of the selected list value command: Callable[[int], ClusterCommand] @@ -105,24 +109,29 @@ class MatterRangeNumber(MatterEntity, NumberEntity): @callback def _update_from_device(self) -> None: """Update from device.""" + # get the value from the primary attribute and convert it to the HA value if needed value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value - self._attr_native_min_value = ( - cast( - int, - self.get_matter_attribute_value(self.entity_description.min_attribute), + + # min case 1: get min from the attribute and convert it + if self.entity_description.min_attribute: + min_value = self.get_matter_attribute_value( + self.entity_description.min_attribute ) - / 100 - ) - self._attr_native_max_value = ( - cast( - int, - self.get_matter_attribute_value(self.entity_description.max_attribute), - ) - / 100 + min_convert = self.entity_description.format_min_value + self._attr_native_min_value = min_convert(min_value) + # min case 2: get the min from entity_description + elif self.entity_description.native_min_value is not None: + self._attr_native_min_value = self.entity_description.native_min_value + + # get max from the attribute and convert it + max_value = self.get_matter_attribute_value( + self.entity_description.max_attribute ) + max_convert = self.entity_description.format_max_value + self._attr_native_max_value = max_convert(max_value) class MatterLevelControlNumber(MatterEntity, NumberEntity): @@ -302,6 +311,27 @@ DISCOVERY_SCHEMAS = [ clusters.OccupancySensing.Attributes.PIROccupiedToUnoccupiedDelay, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="MicrowaveOvenControlCookTime", + translation_key="cook_time", + device_class=NumberDeviceClass.DURATION, + command=lambda value: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=int(value) + ), + native_min_value=1, # 1 second minimum cook time + native_step=1, # 1 second + native_unit_of_measurement=UnitOfTime.SECONDS, + max_attribute=clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.CookTime, + clusters.MicrowaveOvenControl.Attributes.MaxCookTime, + ), + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( @@ -328,6 +358,8 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_to_ha=lambda x: None if x is None else x / 100, ha_to_device=lambda x: round(x * 100), + format_min_value=lambda x: x / 100, + format_max_value=lambda x: x / 100, min_attribute=clusters.TemperatureControl.Attributes.MinTemperature, max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature, mode=NumberMode.SLIDER, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index d700b39258c..5d7a5363da0 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -197,10 +197,14 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - list_values = cast( - list[str], - self.get_matter_attribute_value(self.entity_description.list_attribute), + list_values_raw = self.get_matter_attribute_value( + self.entity_description.list_attribute ) + if TYPE_CHECKING: + assert list_values_raw is not None + + # Accept both list[str] and list[int], convert to str + list_values = [str(v) for v in list_values_raw] self._attr_options = list_values current_option_idx: int = self.get_matter_attribute_value( self._entity_info.primary_attribute @@ -443,6 +447,24 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported rinses list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="MicrowaveOvenControlSelectedWattIndex", + translation_key="power_level", + command=lambda selected_index: clusters.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=selected_index + ), + list_attribute=clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.MicrowaveOvenControl.Attributes.SelectedWattIndex, + clusters.MicrowaveOvenControl.Attributes.SupportedWatts, + ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], + ), MatterDiscoverySchema( platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7f603c9d188..749cf387a40 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -180,6 +180,9 @@ "altitude": { "name": "Altitude above sea level" }, + "cook_time": { + "name": "Cook time" + }, "pump_setpoint": { "name": "Setpoint" }, @@ -222,6 +225,9 @@ "device_energy_management_mode": { "name": "Energy management mode" }, + "power_level": { + "name": "Power level (W)" + }, "sensitivity_level": { "name": "Sensitivity", "state": { diff --git a/tests/components/matter/fixtures/nodes/microwave_oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json index bbba8b12e25..0e693b8337f 100644 --- a/tests/components/matter/fixtures/nodes/microwave_oven.json +++ b/tests/components/matter/fixtures/nodes/microwave_oven.json @@ -397,6 +397,8 @@ "1/96/5": { "0": 0 }, + "1/96/6": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + "1/96/7": 5, "1/96/65532": 2, "1/96/65533": 2, "1/96/65528": [4], diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index da709615610..f7f467b4ed0 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -693,6 +693,65 @@ 'state': '255', }) # --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.microwave_oven_cook_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': 'Cook time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlCookTime-95-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[microwave_oven][number.microwave_oven_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Microwave Oven Cook time', + 'max': 86400, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.microwave_oven_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 092928ff1d4..add827abc5a 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -981,6 +981,79 @@ 'state': 'Low', }) # --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.microwave_oven_power_level_w', + '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': 'Power level (W)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_level', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-MicrowaveOvenControlSelectedWattIndex-95-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[microwave_oven][select.microwave_oven_power_level_w-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Power level (W)', + 'options': list([ + '100', + '200', + '300', + '400', + '500', + '600', + '700', + '800', + '900', + '1000', + ]), + }), + 'context': , + 'entity_id': 'select.microwave_oven_power_level_w', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- # name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 0ba2886b089..b59e6848f63 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -201,3 +201,36 @@ async def test_pump_level( ), # 75 * 2 = 150, as the value is multiplied by 2 in the HA to native value conversion ) ) + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Cooktime for microwave oven.""" + + # Cooktime on MicrowaveOvenControl cluster (1/96/2) + state = hass.states.get("number.microwave_oven_cook_time") + assert state + assert state.state == "30" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.microwave_oven_cook_time", + "value": 60, # 60 seconds + }, + 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.MicrowaveOvenControl.Commands.SetCookingParameters( + cookTime=60, # 60 seconds + ), + ) diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 7045b60a24e..c264f51b669 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -235,3 +235,50 @@ async def test_pump( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.mock_pump_mode") assert state.state == "local" + + +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_microwave_oven( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test ListSelect entity is discovered and working from a microwave oven fixture.""" + + # SupportedWatts from MicrowaveOvenControl cluster (1/96/6) + # SelectedWattIndex from MicrowaveOvenControl cluster (1/96/7) + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.microwave_oven_power_level_w") + assert state + assert state.state == "1000" + assert state.attributes["options"] == [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + ] + + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.microwave_oven_power_level_w", + "option": "900", + }, + 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.MicrowaveOvenControl.Commands.SetCookingParameters( + wattSettingIndex=8 + ), + ) From ebad1ff4cc70641507bd0ce6da1bff2b09223eeb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 28 Jul 2025 11:59:11 +0200 Subject: [PATCH 0677/1118] Fix capitalization of "IP address" in `goalzero` (#149563) --- homeassistant/components/goalzero/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index c6d85bd4c10..8c0477c8f6a 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -12,7 +12,7 @@ } }, "confirm_discovery": { - "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual." + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new IP address. Refer to your router's user manual." } }, "error": { From 18c5437fe708be2e36556a9c306ed055d66225d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 11:42:40 +0100 Subject: [PATCH 0678/1118] Revert "Make default title configurable in XMPP" (#149544) --- homeassistant/components/xmpp/notify.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 59ff3f584b4..c9829746d59 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -48,7 +48,6 @@ ATTR_URL = "url" ATTR_URL_TEMPLATE = "url_template" ATTR_VERIFY = "verify" -CONF_TITLE = "title" CONF_TLS = "tls" CONF_VERIFY = "verify" @@ -65,7 +64,6 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( vol.Optional(CONF_ROOM, default=""): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, - vol.Optional(CONF_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, } ) @@ -84,7 +82,6 @@ async def async_get_service( config.get(CONF_TLS), config.get(CONF_VERIFY), config.get(CONF_ROOM), - config.get(CONF_TITLE), hass, ) @@ -92,9 +89,7 @@ async def async_get_service( class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__( - self, sender, resource, password, recipient, tls, verify, room, title, hass - ): + def __init__(self, sender, resource, password, recipient, tls, verify, room, hass): """Initialize the service.""" self._hass = hass self._sender = sender @@ -104,11 +99,10 @@ class XmppNotificationService(BaseNotificationService): self._tls = tls self._verify = verify self._room = room - self._title = title async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, self._title) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) text = f"{title}: {message}" if title else message data = kwargs.get(ATTR_DATA) timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None From 40ce228c9c599129ecfd12403064570a399900d9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:12:16 +0200 Subject: [PATCH 0679/1118] Add upload_file action to immich integration (#147295) Co-authored-by: Norbert Rittel --- homeassistant/components/immich/__init__.py | 12 + homeassistant/components/immich/icons.json | 5 + homeassistant/components/immich/services.py | 98 +++++++ homeassistant/components/immich/services.yaml | 18 ++ homeassistant/components/immich/strings.json | 37 +++ tests/components/immich/conftest.py | 29 +- tests/components/immich/test_services.py | 277 ++++++++++++++++++ 7 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/immich/services.py create mode 100644 homeassistant/components/immich/services.yaml create mode 100644 tests/components/immich/test_services.py diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index d40615dbe88..996e4f3ad8c 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -16,13 +16,25 @@ from homeassistant.const import ( ) 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 from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up immich integration.""" + await async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Set up Immich from a config entry.""" diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json index 15bac6370a6..aefce3ed615 100644 --- a/homeassistant/components/immich/icons.json +++ b/homeassistant/components/immich/icons.json @@ -11,5 +11,10 @@ "default": "mdi:file-video" } } + }, + "services": { + "upload_file": { + "service": "mdi:upload" + } } } diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py new file mode 100644 index 00000000000..fffd5d9110b --- /dev/null +++ b/homeassistant/components/immich/services.py @@ -0,0 +1,98 @@ +"""Services for the Immich integration.""" + +import logging + +from aioimmich.exceptions import ImmichError +import voluptuous as vol + +from homeassistant.components.media_source import async_resolve_media +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import MediaSelector + +from .const import DOMAIN +from .coordinator import ImmichConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONF_ALBUM_ID = "album_id" +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_FILE = "file" + +SERVICE_UPLOAD_FILE = "upload_file" +SERVICE_SCHEMA_UPLOAD_FILE = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): str, + vol.Required(CONF_FILE): MediaSelector({"accept": ["image/*", "video/*"]}), + vol.Optional(CONF_ALBUM_ID): str, + } +) + + +async def _async_upload_file(service_call: ServiceCall) -> None: + """Call immich upload file service.""" + _LOGGER.debug( + "Executing service %s with arguments %s", + service_call.service, + service_call.data, + ) + hass = service_call.hass + target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry( + service_call.data[CONF_CONFIG_ENTRY_ID] + ) + source_media_id = service_call.data[CONF_FILE]["media_content_id"] + + if not target_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + if target_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + media = await async_resolve_media(hass, source_media_id, None) + if media.path is None: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="only_local_media_supported" + ) + + coordinator = target_entry.runtime_data + + if target_album := service_call.data.get(CONF_ALBUM_ID): + try: + await coordinator.api.albums.async_get_album_info(target_album, True) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="album_not_found", + translation_placeholders={"album_id": target_album, "error": str(ex)}, + ) from ex + + try: + upload_result = await coordinator.api.assets.async_upload_asset(str(media.path)) + if target_album: + await coordinator.api.albums.async_add_assets_to_album( + target_album, [upload_result.asset_id] + ) + except ImmichError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="upload_failed", + translation_placeholders={"file": str(media.path), "error": str(ex)}, + ) from ex + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for immich integration.""" + + hass.services.async_register( + DOMAIN, + SERVICE_UPLOAD_FILE, + _async_upload_file, + SERVICE_SCHEMA_UPLOAD_FILE, + ) diff --git a/homeassistant/components/immich/services.yaml b/homeassistant/components/immich/services.yaml new file mode 100644 index 00000000000..7924a6a112c --- /dev/null +++ b/homeassistant/components/immich/services.yaml @@ -0,0 +1,18 @@ +upload_file: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: immich + file: + required: true + selector: + media: + accept: + - image/* + - video/* + album_id: + required: false + selector: + text: diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 83ee7574630..90fccfa1bb1 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -74,5 +74,42 @@ "name": "Version" } } + }, + "services": { + "upload_file": { + "name": "Upload file", + "description": "Uploads a file to your Immich instance.", + "fields": { + "config_entry_id": { + "name": "Immich instance", + "description": "The Immich instance where to upload the file." + }, + "file": { + "name": "File", + "description": "The path to the file to be uploaded." + }, + "album_id": { + "name": "Album ID", + "description": "The album in which the file should be placed after uploading." + } + } + } + }, + "exceptions": { + "config_entry_not_found": { + "message": "Config entry not found." + }, + "config_entry_not_loaded": { + "message": "Config entry not loaded." + }, + "only_local_media_supported": { + "message": "Only local media files are currently supported." + }, + "album_not_found": { + "message": "Album with ID `{album_id}` not found ({error})." + }, + "upload_failed": { + "message": "Upload of file `{file}` failed ({error})." + } } } diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 6c7813cbd85..48e36e70386 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -1,9 +1,12 @@ """Common fixtures for the Immich tests.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, patch +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse +from aioimmich.assets.models import ImmichAssetUploadResponse from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, @@ -14,6 +17,7 @@ from aioimmich.users.models import ImmichUserObject import pytest from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.media_source import PlayMedia from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -62,6 +66,12 @@ def mock_immich_albums() -> AsyncMock: mock = AsyncMock(spec=ImmichAlbums) mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS] mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS + mock.async_add_assets_to_album.return_value = [ + ImmichAddAssetsToAlbumResponse.from_dict( + {"id": "abcdef-0123456789", "success": True} + ) + ] + return mock @@ -71,6 +81,9 @@ def mock_immich_assets() -> AsyncMock: mock = AsyncMock(spec=ImmichAssests) mock.async_view_asset.return_value = b"xxxx" mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx") + mock.async_upload_asset.return_value = ImmichAssetUploadResponse.from_dict( + {"id": "abcdef-0123456789", "status": "created"} + ) return mock @@ -195,6 +208,20 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: return mock_immich +@pytest.fixture +def mock_media_source() -> Generator[MagicMock]: + """Mock the media source.""" + with patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/local/screenshot.jpg", + mime_type="image/jpeg", + path=Path("/media/screenshot.jpg"), + ), + ) as mock_media: + yield mock_media + + @pytest.fixture async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" diff --git a/tests/components/immich/test_services.py b/tests/components/immich/test_services.py new file mode 100644 index 00000000000..5ba7cf96408 --- /dev/null +++ b/tests/components/immich/test_services.py @@ -0,0 +1,277 @@ +"""Test the Immich services.""" + +from unittest.mock import Mock, patch + +from aioimmich.exceptions import ImmichError, ImmichNotFoundError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.immich.services import SERVICE_UPLOAD_FILE +from homeassistant.components.media_source import PlayMedia +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_services( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup of immich services.""" + await setup_integration(hass, mock_config_entry) + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_UPLOAD_FILE in services + + +async def test_upload_file( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_not_called() + mock_immich.albums.async_add_assets_to_album.assert_not_called() + + +async def test_upload_file_to_album( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg") + mock_immich.albums.async_get_album_info.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", True + ) + mock_immich.albums.async_add_assets_to_album.assert_called_with( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", ["abcdef-0123456789"] + ) + + +async def test_upload_file_config_entry_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_found.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not found"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": "unknown_entry", + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_config_entry_not_loaded( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload_file service raising config_entry_not_loaded.""" + mock_config_entry.disabled_by = er.RegistryEntryDisabler.USER + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError, match="Config entry not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_only_local_media_supported( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising only_local_media_supported.""" + await setup_integration(hass, mock_config_entry) + with ( + patch( + "homeassistant.components.immich.services.async_resolve_media", + return_value=PlayMedia( + url="media-source://media_source/camera/some_entity_id", + mime_type="image/jpeg", + path=None, # Simulate non-local media + ), + ), + pytest.raises( + ServiceValidationError, + match="Only local media files are currently supported", + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_album_not_found( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising album_not_found.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_get_album_info.side_effect = ImmichNotFoundError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + + with pytest.raises( + ServiceValidationError, + match="Album with ID `721e1a4b-aa12-441e-8d3b-5ac7ab283bb6` not found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) + + +async def test_upload_file_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.assets.async_upload_asset.side_effect = ImmichError( + { + "message": "Boom! Upload failed", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + }, + blocking=True, + ) + + +async def test_upload_file_to_album_upload_failed( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + mock_media_source: Mock, +) -> None: + """Test upload_file service with target album_id raising upload_failed.""" + await setup_integration(hass, mock_config_entry) + + mock_immich.albums.async_add_assets_to_album.side_effect = ImmichError( + { + "message": "Boom! Add to album failed.", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "nyzxjkno", + } + ) + with pytest.raises( + ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD_FILE, + { + "config_entry_id": mock_config_entry.entry_id, + "file": { + "media_content_id": "media-source://media_source/local/screenshot.jpg", + "media_content_type": "image/jpeg", + }, + "album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + }, + blocking=True, + ) From 140f56aeaa4d7fe4be143a8c5f67c56c9afa90b2 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 07:12:52 -0400 Subject: [PATCH 0680/1118] Add common translation strings (#149472) Co-authored-by: Martin Hjelmare --- .../components/template/strings.json | 157 ++++++++++-------- 1 file changed, 84 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index a8c2e7660dc..e178b383a78 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,10 +1,21 @@ { + "common": { + "advanced_options": "Advanced options", + "availability": "Availability template", + "code_format": "Code format", + "device_class": "Device class", + "device_id_description": "Select a device to link to this entity.", + "state": "State", + "turn_off": "Actions on turn off", + "turn_on": "Actions on turn on", + "unit_of_measurement": "Unit of measurement" + }, "config": { "step": { "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "name": "[%key:common::config_flow::data::name%]", "disarm": "Disarm action", "arm_away": "Arm away action", @@ -14,16 +25,16 @@ "arm_vacation": "Arm vacation action", "trigger": "Trigger action", "code_arm_required": "Code arm required", - "code_format": "Code format" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -32,18 +43,18 @@ "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -52,18 +63,18 @@ "button": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "press": "Actions on press" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -77,13 +88,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -93,21 +104,21 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "Step value", "set_value": "Actions on set value", "max": "Maximum value", "min": "Minimum value", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -117,18 +128,18 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "Actions on select", "options": "Available options" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -137,20 +148,20 @@ "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "Device class", + "device_class": "[%key:component::template::common::device_class%]", "name": "[%key:common::config_flow::data::name%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "State template", - "unit_of_measurement": "Unit of measurement" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "Select a device to link to this entity." + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "Advanced options", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "Availability template" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -174,19 +185,19 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "turn_off": "Actions on turn off", - "turn_on": "Actions on turn on", - "value_template": "Value template" + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "value_template": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", + "device_id": "[%key:component::template::common::device_id_description%]", "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -199,7 +210,7 @@ "alarm_control_panel": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "value_template": "[%key:component::template::common::state%]", "disarm": "[%key:component::template::config::step::alarm_control_panel::data::disarm%]", "arm_away": "[%key:component::template::config::step::alarm_control_panel::data::arm_away%]", "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data::arm_custom_bypass%]", @@ -208,16 +219,16 @@ "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data::arm_vacation%]", "trigger": "[%key:component::template::config::step::alarm_control_panel::data::trigger%]", "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data::code_arm_required%]", - "code_format": "[%key:component::template::config::step::alarm_control_panel::data::code_format%]" + "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -226,16 +237,16 @@ "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "state": "[%key:component::template::config::step::sensor::data::state%]" + "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -247,13 +258,13 @@ "press": "[%key:component::template::config::step::button::data::press%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -266,13 +277,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -282,20 +293,20 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "step": "[%key:component::template::config::step::number::data::step%]", "set_value": "[%key:component::template::config::step::number::data::set_value%]", "max": "[%key:component::template::config::step::number::data::max%]", "min": "[%key:component::template::config::step::number::data::min%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -305,18 +316,18 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", + "state": "[%key:component::template::common::state%]", "select_option": "[%key:component::template::config::step::select::data::select_option%]", "options": "[%key:component::template::config::step::select::data::options%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -325,19 +336,19 @@ "sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "device_class": "[%key:component::template::common::device_class%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state": "[%key:component::template::config::step::sensor::data::state%]", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + "state": "[%key:component::template::common::state%]", + "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + "device_id": "[%key:component::template::common::device_id_description%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, @@ -347,19 +358,19 @@ "data": { "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", - "value_template": "[%key:component::template::config::step::switch::data::value_template%]", - "turn_off": "[%key:component::template::config::step::switch::data::turn_off%]", - "turn_on": "[%key:component::template::config::step::switch::data::turn_on%]" + "value_template": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]" }, "data_description": { - "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]", + "device_id": "[%key:component::template::common::device_id_description%]", "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, "sections": { "advanced_options": { - "name": "[%key:component::template::config::step::sensor::sections::advanced_options::name%]", + "name": "[%key:component::template::common::advanced_options%]", "data": { - "availability": "[%key:component::template::config::step::sensor::sections::advanced_options::data::availability%]" + "availability": "[%key:component::template::common::availability%]" } } }, From 95c5a91f01f7ff8fc923a0ad20f6a1049e24eb45 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:13:08 +0200 Subject: [PATCH 0681/1118] Refactor active session handling in PlaystationNetwork (#149559) --- .../components/playstation_network/helpers.py | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 358e1c13025..9960d8afd79 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -107,30 +107,34 @@ class PlaystationNetwork: data.shareable_profile_link = self.shareable_profile_link data.availability = data.presence["basicPresence"]["availability"] - session = SessionData() - session.platform = PlatformType( - data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] - ) - - if session.platform in SUPPORTED_PLATFORMS: - session.status = data.presence.get("basicPresence", {}).get( - "primaryPlatformInfo" - )["onlineStatus"] - - game_title_info = data.presence.get("basicPresence", {}).get( - "gameTitleInfoList" + if "platform" in data.presence["basicPresence"]["primaryPlatformInfo"]: + primary_platform = PlatformType( + data.presence["basicPresence"]["primaryPlatformInfo"]["platform"] + ) + game_title_info: dict[str, Any] = next( + iter( + data.presence.get("basicPresence", {}).get("gameTitleInfoList", []) + ), + {}, + ) + status = data.presence.get("basicPresence", {}).get("primaryPlatformInfo")[ + "onlineStatus" + ] + title_format = ( + PlatformType(fmt) if (fmt := game_title_info.get("format")) else None ) - if game_title_info: - session.title_id = game_title_info[0]["npTitleId"] - session.title_name = game_title_info[0]["titleName"] - session.format = PlatformType(game_title_info[0]["format"]) - if session.format in {PlatformType.PS5, PlatformType.PSPC}: - session.media_image_url = game_title_info[0]["conceptIconUrl"] - else: - session.media_image_url = game_title_info[0]["npTitleIconUrl"] - - data.active_sessions[session.platform] = session + data.active_sessions[primary_platform] = SessionData( + platform=primary_platform, + status=status, + title_id=game_title_info.get("npTitleId"), + title_name=game_title_info.get("titleName"), + format=title_format, + media_image_url=( + game_title_info.get("conceptIconUrl") + or game_title_info.get("npTitleIconUrl") + ), + ) if self.legacy_profile: presence = self.legacy_profile["profile"].get("presences", []) From 850e04d9aaecde7f6a53bd7f728552bb5d2074dc Mon Sep 17 00:00:00 2001 From: wollew Date: Mon, 28 Jul 2025 13:15:59 +0200 Subject: [PATCH 0682/1118] Add binary sensor for rain detection for Velux windows that have them (#148275) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/velux/binary_sensor.py | 63 +++++++++++++++++++ homeassistant/components/velux/const.py | 2 +- tests/components/velux/conftest.py | 53 +++++++++++++++- tests/components/velux/test_binary_sensor.py | 50 +++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/velux/binary_sensor.py create mode 100644 tests/components/velux/test_binary_sensor.py diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py new file mode 100644 index 00000000000..e08d4bcf545 --- /dev/null +++ b/homeassistant/components/velux/binary_sensor.py @@ -0,0 +1,63 @@ +"""Support for rain sensors build into some velux windows.""" + +from __future__ import annotations + +from datetime import timedelta + +from pyvlx.exception import PyVLXException +from pyvlx.opening_device import OpeningDevice, Window + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import VeluxEntity + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up rain sensor(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + VeluxRainSensor(node, config.entry_id) + for node in module.pyvlx.nodes + if isinstance(node, Window) and node.rain_sensor + ) + + +class VeluxRainSensor(VeluxEntity, BinarySensorEntity): + """Representation of a Velux rain sensor.""" + + node: Window + _attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices + _attr_entity_registry_enabled_default = False + _attr_device_class = BinarySensorDeviceClass.MOISTURE + + def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + """Initialize VeluxRainSensor.""" + super().__init__(node, config_entry_id) + self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor" + self._attr_name = f"{node.name} Rain sensor" + + async def async_update(self) -> None: + """Fetch the latest state from the device.""" + try: + limitation = await self.node.get_limitation() + except PyVLXException: + LOGGER.error("Error fetching limitation data for cover %s", self.name) + return + + # Velux windows with rain sensors report an opening limitation of 93 when rain is detected. + self._attr_is_on = limitation.min_value == 93 diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index 49a762e87ca..46663383250 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -5,5 +5,5 @@ from logging import getLogger from homeassistant.const import Platform DOMAIN = "velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE] LOGGER = getLogger(__package__) diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index c88a21d2bba..1b7066577ad 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -1,16 +1,18 @@ """Configuration for Velux tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.velux import DOMAIN +from homeassistant.components.velux.binary_sensor import Window from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from tests.common import MockConfigEntry +# Fixtures for the config flow tests @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -59,3 +61,52 @@ def mock_discovered_config_entry() -> MockConfigEntry: }, unique_id="VELUX_KLF_ABCD", ) + + +# fixtures for the binary sensor tests +@pytest.fixture +def mock_window() -> AsyncMock: + """Create a mock Velux window with a rain sensor.""" + window = AsyncMock(spec=Window, autospec=True) + window.name = "Test Window" + window.rain_sensor = True + window.serial_number = "123456789" + window.get_limitation.return_value = MagicMock(min_value=0) + return window + + +@pytest.fixture +def mock_pyvlx(mock_window: MagicMock) -> MagicMock: + """Create the library mock.""" + pyvlx = MagicMock() + pyvlx.nodes = [mock_window] + pyvlx.load_scenes = AsyncMock() + pyvlx.load_nodes = AsyncMock() + pyvlx.disconnect = AsyncMock() + return pyvlx + + +@pytest.fixture +def mock_module(mock_pyvlx: MagicMock) -> Generator[AsyncMock]: + """Create the Velux module mock.""" + with ( + patch( + "homeassistant.components.velux.VeluxModule", + autospec=True, + ) as mock_velux, + ): + module = mock_velux.return_value + module.pyvlx = mock_pyvlx + yield module + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "testhost", + CONF_PASSWORD: "testpw", + }, + ) diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py new file mode 100644 index 00000000000..8eb065a5a46 --- /dev/null +++ b/tests/components/velux/test_binary_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the Velux binary sensor platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_module") +async def test_rain_sensor_state( + hass: HomeAssistant, + mock_window: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the rain sensor.""" + mock_config_entry.add_to_hass(hass) + + test_entity_id = "binary_sensor.test_window_rain_sensor" + + with ( + patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]), + ): + # setup config entry + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # simulate no rain detected + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OFF + + # simulate rain detected + mock_window.get_limitation.return_value.min_value = 93 + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_ON From 4ad35e842150f7fef363d5c98b173846a62ec788 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 28 Jul 2025 14:18:43 +0300 Subject: [PATCH 0683/1118] Add charging binary sensor to `ituran` (#149562) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ituran/__init__.py | 1 + .../components/ituran/binary_sensor.py | 75 +++++++++++++++++++ tests/components/ituran/conftest.py | 2 + .../ituran/snapshots/test_binary_sensor.ambr | 50 +++++++++++++ tests/components/ituran/test_binary_sensor.py | 73 ++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 homeassistant/components/ituran/binary_sensor.py create mode 100644 tests/components/ituran/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ituran/test_binary_sensor.py diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index bf9cff238cd..41392c5cee1 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR, ] diff --git a/homeassistant/components/ituran/binary_sensor.py b/homeassistant/components/ituran/binary_sensor.py new file mode 100644 index 00000000000..8a18cca8968 --- /dev/null +++ b/homeassistant/components/ituran/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from propcache.api import cached_property +from pyituran import Vehicle + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Ituran binary sensor entity.""" + + value_fn: Callable[[Vehicle], bool] + supported_fn: Callable[[Vehicle], bool] = lambda _: True + + +BINARY_SENSOR_TYPES: list[IturanBinarySensorEntityDescription] = [ + IturanBinarySensorEntityDescription( + key="is_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda vehicle: vehicle.is_charging, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Ituran binary sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanBinarySensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() + for description in BINARY_SENSOR_TYPES + if description.supported_fn(vehicle) + ) + + +class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity): + """Ituran binary sensor.""" + + entity_description: IturanBinarySensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @cached_property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 1cb922b94e9..7582a2a6645 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -65,9 +65,11 @@ class MockVehicle: if is_electric_vehicle: self.battery_level = 42 self.battery_range = 150 + self.is_charging = True else: self.battery_level = 0 self.battery_range = 0 + self.is_charging = False @pytest.fixture diff --git a/tests/components/ituran/snapshots/test_binary_sensor.ambr b/tests/components/ituran/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fed9f2b487c --- /dev/null +++ b/tests/components/ituran/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_model_charging', + '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', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-is_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'mock model Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_model_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ituran/test_binary_sensor.py b/tests/components/ituran/test_binary_sensor.py new file mode 100644 index 00000000000..1eb2fca6f4c --- /dev/null +++ b/tests/components/ituran/test_binary_sensor.py @@ -0,0 +1,73 @@ +"""Test the Ituran binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "binary_sensor.mock_model_charging", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE From db1e6a0d986163ad3f9585514cf0a0ff93629c6e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:34:27 +0200 Subject: [PATCH 0684/1118] Add quality scale and set Silver for Tankerkoenig (#143418) --- .../components/tankerkoenig/manifest.json | 1 + .../tankerkoenig/quality_scale.yaml | 81 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/tankerkoenig/quality_scale.yaml diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 72248d006e0..5dc75e4cc90 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], + "quality_scale": "silver", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/tankerkoenig/quality_scale.yaml b/homeassistant/components/tankerkoenig/quality_scale.yaml new file mode 100644 index 00000000000..666d927adb5 --- /dev/null +++ b/homeassistant/components/tankerkoenig/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No custom actions provided. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No custom actions provided. + 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: No custom actions provided. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: It's a pure webservice, without real devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Each config entry represents one service entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + All possible changes are already covered by re-auth and options flow. + repair-issues: + status: exempt + comment: No repair issues implemented. + stale-devices: + status: exempt + comment: Each config entry represents one service entry. + + # 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 b42e1e415aa..def20d9d4cc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -971,7 +971,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "tailscale", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", @@ -2029,7 +2028,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "tailwind", "tami4", "tank_utility", - "tankerkoenig", "tapsaff", "tasmota", "tautulli", From bf05c23414116761f0dc4ab8a473f5bc7fa9c670 Mon Sep 17 00:00:00 2001 From: wittypluck Date: Mon, 28 Jul 2025 14:40:00 +0200 Subject: [PATCH 0685/1118] Update OpenWeatherMap config step description to clarify API key documentation (#146843) --- homeassistant/components/openweathermap/config_flow.py | 4 ++++ homeassistant/components/openweathermap/strings.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 4c66778119e..76a32af13b0 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -69,6 +69,10 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=data, options=options ) + description_placeholders["doc_url"] = ( + "https://www.home-assistant.io/integrations/openweathermap/" + ) + schema = vol.Schema( { vol.Required(CONF_API_KEY): str, diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 1aa161c87dc..51de5cf2244 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -17,7 +17,7 @@ "mode": "[%key:common::config_flow::data::mode%]", "name": "[%key:common::config_flow::data::name%]" }, - "description": "To generate API key go to https://openweathermap.org/appid" + "description": "To generate an API key, please refer to the [integration documentation]({doc_url})" } } }, From 48c4240a5ddc5657b2fbfca1858cb9391edabde6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:48:45 -0400 Subject: [PATCH 0686/1118] Delete unused switch platform code (#149468) --- homeassistant/components/template/switch.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index bd271e4b17c..f5835f2d478 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -94,16 +94,6 @@ SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( ) -def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: - """Rewrite option configuration to modern configuration.""" - option_config = {**option_config} - - if CONF_VALUE_TEMPLATE in option_config: - option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) - - return option_config - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, From 46d810b9f95b0b8bfd42019b64f3d806f52a1a4d Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 28 Jul 2025 20:52:40 +0800 Subject: [PATCH 0687/1118] Better error handling when setting up config entry for Telegram bot (#149444) --- .../components/telegram_bot/config_flow.py | 8 ++++-- .../components/telegram_bot/strings.json | 1 + .../telegram_bot/test_config_flow.py | 26 ++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 8d3d9b0cd7b..c71d8a1ad1e 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -7,7 +7,7 @@ from types import MappingProxyType from typing import Any from telegram import Bot, ChatFullInfo -from telegram.error import BadRequest, InvalidToken, NetworkError +from telegram.error import BadRequest, InvalidToken, TelegramError import voluptuous as vol from homeassistant.config_entries import ( @@ -399,13 +399,17 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): placeholders[ERROR_FIELD] = "API key" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" - except (ValueError, NetworkError) as err: + except ValueError as err: _LOGGER.warning("Invalid proxy") errors["base"] = "invalid_proxy_url" placeholders["proxy_url_error"] = str(err) placeholders[ERROR_FIELD] = "proxy url" placeholders[ERROR_MESSAGE] = str(err) return "Unknown bot" + except TelegramError as err: + errors["base"] = "telegram_error" + placeholders[ERROR_MESSAGE] = str(err) + return "Unknown bot" else: return user.full_name diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index df3de556efb..29bf51ecd0c 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -66,6 +66,7 @@ } }, "error": { + "telegram_error": "Error from Telegram: {error_message}", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "invalid_proxy_url": "{proxy_url_error}", "no_url_available": "URL is required since you have not configured an external URL in Home Assistant", diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 9a076016a32..0886246b7e1 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -221,10 +221,29 @@ async def test_create_entry(hass: HomeAssistant) -> None: # test: invalid proxy url + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PLATFORM: PLATFORM_WEBHOOKS, + CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_proxy_url" + assert result["description_placeholders"]["error_field"] == "proxy url" + + # test: telegram error + with patch( "homeassistant.components.telegram_bot.config_flow.Bot.get_me", ) as mock_bot: - mock_bot.side_effect = NetworkError("mock invalid proxy") + mock_bot.side_effect = NetworkError("mock network error") result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -232,7 +251,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", SECTION_ADVANCED_SETTINGS: { - CONF_PROXY_URL: "invalid", + CONF_PROXY_URL: "https://proxy", }, }, ) @@ -240,7 +259,8 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "invalid_proxy_url" + assert result["errors"]["base"] == "telegram_error" + assert result["description_placeholders"]["error_message"] == "mock network error" # test: valid input, to continue with webhooks step From a71eecaaa4d7a18e98dcf067da4cdf0379a21f12 Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Mon, 28 Jul 2025 09:10:55 -0400 Subject: [PATCH 0688/1118] Update datadog test logic (#149459) Co-authored-by: Joostlek --- .../components/datadog/config_flow.py | 6 +- tests/components/datadog/test_config_flow.py | 38 +++++- tests/components/datadog/test_init.py | 114 ++++++++++-------- 3 files changed, 98 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py index b4486b0967c..876b79b6019 100644 --- a/homeassistant/components/datadog/config_flow.py +++ b/homeassistant/components/datadog/config_flow.py @@ -36,14 +36,14 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): """Handle user config flow.""" errors: dict[str, str] = {} if user_input: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) # Validate connection to Datadog Agent success = await validate_datadog_connection( self.hass, user_input, ) - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) if not success: errors["base"] = "cannot_connect" else: diff --git a/tests/components/datadog/test_config_flow.py b/tests/components/datadog/test_config_flow.py index 7950bb2c17d..1d181774fbe 100644 --- a/tests/components/datadog/test_config_flow.py +++ b/tests/components/datadog/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch from homeassistant.components import datadog -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir @@ -22,7 +22,7 @@ async def test_user_flow_success(hass: HomeAssistant) -> None: mock_dogstatsd.return_value = mock_instance result = await hass.config_entries.flow.async_init( - datadog.DOMAIN, context={"source": "user"} + datadog.DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -42,7 +42,7 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non side_effect=OSError("Connection failed"), ): result = await hass.config_entries.flow.async_init( - datadog.DOMAIN, context={"source": "user"} + datadog.DOMAIN, context={"source": SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( @@ -62,6 +62,34 @@ async def test_user_flow_retry_after_connection_fail(hass: HomeAssistant) -> Non assert result3["options"] == MOCK_OPTIONS +async def test_user_flow_abort_already_configured_service( + hass: HomeAssistant, +) -> None: + """Abort user-initiated config flow if the same host/port is already configured.""" + existing_entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + datadog.DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None: """Test that the options flow shows an error when connection fails.""" mock_entry = MockConfigEntry( @@ -221,9 +249,9 @@ async def test_import_flow_abort_already_configured_service( result = await hass.config_entries.flow.async_init( datadog.DOMAIN, - context={"source": "import"}, + context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG, ) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 73bce96d16c..3c22aaeee8f 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -8,57 +8,65 @@ from homeassistant.components.datadog import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .common import MOCK_DATA, MOCK_OPTIONS, create_mock_state -from tests.common import EVENT_STATE_CHANGED, MockConfigEntry, assert_setup_component +from tests.common import EVENT_STATE_CHANGED, MockConfigEntry async def test_invalid_config(hass: HomeAssistant) -> None: """Test invalid configuration.""" - with assert_setup_component(0): - assert not await async_setup_component( - hass, datadog.DOMAIN, {datadog.DOMAIN: {"host1": "host1"}} - ) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={"host1": "host1"}, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) async def test_datadog_setup_full(hass: HomeAssistant) -> None: """Test setup with all data.""" - config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} - with ( - patch( - "homeassistant.components.datadog.config_flow.DogStatsd" - ) as mock_dogstatsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component(hass, datadog.DOMAIN, config) + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": 123, + }, + options={ + "rate": 1, + "prefix": "foo", + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert mock_dogstatsd.call_count == 1 - assert mock_dogstatsd.call_args == mock.call("host", 123) + assert mock_dogstatsd.call_args == mock.call( + host="host", port=123, namespace="foo" + ) async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" with ( - patch( - "homeassistant.components.datadog.config_flow.DogStatsd" - ) as mock_dogstatsd, + patch("homeassistant.components.datadog.DogStatsd") as mock_dogstatsd, ): - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "port": datadog.DEFAULT_PORT, - "prefix": datadog.DEFAULT_PREFIX, - } - }, + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) assert mock_dogstatsd.call_count == 1 - assert mock_dogstatsd.call_args == mock.call("host", 8125) + assert mock_dogstatsd.call_args == mock.call( + host="localhost", port=8125, namespace="hass" + ) async def test_logbook_entry(hass: HomeAssistant) -> None: @@ -70,24 +78,24 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: ), ): mock_statsd = mock_statsd_class.return_value - - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "port": datadog.DEFAULT_PORT, - "rate": datadog.DEFAULT_RATE, - "prefix": datadog.DEFAULT_PREFIX, - } + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": datadog.DEFAULT_HOST, + "port": datadog.DEFAULT_PORT, + }, + options={ + "rate": datadog.DEFAULT_RATE, + "prefix": datadog.DEFAULT_PREFIX, }, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) event = { "domain": "automation", "entity_id": "sensor.foo.bar", - "message": "foo bar biz", + "message": "foo bar baz", "name": "triggered something", } hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, event) @@ -110,18 +118,16 @@ async def test_state_changed(hass: HomeAssistant) -> None: ), ): mock_statsd = mock_statsd_class.return_value - - assert await async_setup_component( - hass, - datadog.DOMAIN, - { - datadog.DOMAIN: { - "host": "host", - "prefix": "ha", - "rate": datadog.DEFAULT_RATE, - } + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data={ + "host": "host", + "port": datadog.DEFAULT_PORT, }, + options={"prefix": "ha", "rate": datadog.DEFAULT_RATE}, ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0} @@ -191,14 +197,18 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_state_changed_skips_unknown(hass: HomeAssistant) -> None: """Test state_changed_listener skips None and unknown states.""" - entry = MockConfigEntry(domain=datadog.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS) - entry.add_to_hass(hass) - with ( patch( "homeassistant.components.datadog.config_flow.DogStatsd" ) as mock_dogstatsd, ): + entry = MockConfigEntry( + domain=datadog.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, + ) + entry.add_to_hass(hass) + await async_setup_entry(hass, entry) # Test None state From 2a5448835fce78e12549ca8ad538fba15e78b8ed Mon Sep 17 00:00:00 2001 From: jennoian <39549658+jennoian@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:37:37 +0100 Subject: [PATCH 0689/1118] Add Vacuum support to smartthings (#148724) Co-authored-by: Joostlek Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/vacuum.py | 95 +++++++++++++ .../device_status/da_rvc_map_01011.json | 2 +- .../smartthings/snapshots/test_vacuum.ambr | 99 +++++++++++++ tests/components/smartthings/test_vacuum.py | 133 ++++++++++++++++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smartthings/vacuum.py create mode 100644 tests/components/smartthings/snapshots/test_vacuum.ambr create mode 100644 tests/components/smartthings/test_vacuum.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4259e4182c..9c7621037c7 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,6 +103,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py new file mode 100644 index 00000000000..59152842150 --- /dev/null +++ b/homeassistant/components/smartthings/vacuum.py @@ -0,0 +1,95 @@ +"""SmartThings vacuum platform.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pysmartthings import Attribute, Command, SmartThings +from pysmartthings.capability import Capability + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up vacuum entities from SmartThings devices.""" + entry_data = entry.runtime_data + async_add_entities( + SamsungJetBotVacuum(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE in device.status[MAIN] + ) + + +class SamsungJetBotVacuum(SmartThingsEntity, StateVacuumEntity): + """Representation of a Vacuum.""" + + _attr_name = None + _attr_supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STATE + ) + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the Samsung robot cleaner vacuum entity.""" + super().__init__( + client, + device, + {Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE}, + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity based on operating state.""" + status = self.get_attribute_value( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + ) + + return { + "cleaning": VacuumActivity.CLEANING, + "homing": VacuumActivity.RETURNING, + "idle": VacuumActivity.IDLE, + "paused": VacuumActivity.PAUSED, + "docked": VacuumActivity.DOCKED, + "error": VacuumActivity.ERROR, + "charging": VacuumActivity.DOCKED, + }.get(status) + + async def async_start(self) -> None: + """Start the vacuum's operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.START, + ) + + async def async_pause(self) -> None: + """Pause the vacuum's current operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, Command.PAUSE + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return the vacuum to its base.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.RETURN_TO_HOME, + ) diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json index 14244935308..686207f67d2 100644 --- a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -878,7 +878,7 @@ "timestamp": "2025-06-20T14:12:58.012Z" }, "operatingState": { - "value": "dryingMop", + "value": "charging", "timestamp": "2025-07-10T09:52:40.510Z" }, "cleaningStep": { diff --git a/tests/components/smartthings/snapshots/test_vacuum.ambr b/tests/components/smartthings/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..59bbae2b3e7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_vacuum.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + '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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + '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, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/smartthings/test_vacuum.py b/tests/components/smartthings/test_vacuum.py new file mode 100644 index 00000000000..6e2406625eb --- /dev/null +++ b/tests/components/smartthings/test_vacuum.py @@ -0,0 +1,133 @@ +"""Test for the SmartThings vacuum platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VACUUM) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_START, Command.START), + (SERVICE_PAUSE, Command.PAUSE), + (SERVICE_RETURN_TO_BASE, Command.RETURN_TO_HOME), + ], +) +async def test_vacuum_actions( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test vacuum actions.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VACUUM_DOMAIN, + action, + {ATTR_ENTITY_ID: "vacuum.robot_vacuum"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_update( + hass, + devices, + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + "error", + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.ERROR + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.OFFLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.ONLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE From d088fccb8833985baf6441cdd675ae4335fd48f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?alvi=20kazi=20=F0=9F=87=A7=F0=9F=87=A9?= Date: Mon, 28 Jul 2025 22:51:07 +0900 Subject: [PATCH 0690/1118] VeSync: add support for LAP-V102S-WJP air purifier (#149102) --- homeassistant/components/vesync/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 08db4463e07..6d818b463d8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -129,6 +129,7 @@ SKU_TO_BASE_DEVICE = { "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S + "LAP-V102S-WJP": "Vital100S", # Alt ID Model Vital100S "EverestAir": "EverestAir", "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir From 386f709fd3814d0a014ba18e60a5256c92a5e1e0 Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:00:22 +0300 Subject: [PATCH 0691/1118] Osoenergy holiday mode services (#149430) Co-authored-by: Joost Lekkerkerker --- .../components/osoenergy/water_heater.py | 17 ++++++++- tests/components/osoenergy/conftest.py | 2 ++ .../osoenergy/fixtures/water_heater.json | 3 +- .../snapshots/test_water_heater.ambr | 5 +-- .../components/osoenergy/test_water_heater.py | 36 +++++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index 07820ee97d5..c271330bacd 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -164,7 +164,9 @@ class OSOEnergyWaterHeater( _attr_name = None _attr_supported_features = ( - WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF ) _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -203,6 +205,11 @@ class OSOEnergyWaterHeater( """Return the current temperature of the heater.""" return self.entity_data.current_temperature + @property + def is_away_mode_on(self) -> bool: + """Return if the heater is in away mode.""" + return self.entity_data.isInPowerSave + @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" @@ -228,6 +235,14 @@ class OSOEnergyWaterHeater( """Return the maximum temperature.""" return self.entity_data.max_temperature + async def async_turn_away_mode_on(self) -> None: + """Turn on away mode.""" + await self.osoenergy.hotwater.enable_holiday_mode(self.entity_data) + + async def async_turn_away_mode_off(self) -> None: + """Turn off away mode.""" + await self.osoenergy.hotwater.disable_holiday_mode(self.entity_data) + async def async_turn_on(self, **kwargs) -> None: """Turn on hotwater.""" await self.osoenergy.hotwater.turn_on(self.entity_data, True) diff --git a/tests/components/osoenergy/conftest.py b/tests/components/osoenergy/conftest.py index bb14fec0241..915761ba6d3 100644 --- a/tests/components/osoenergy/conftest.py +++ b/tests/components/osoenergy/conftest.py @@ -74,6 +74,8 @@ async def mock_osoenergy_client(mock_water_heater) -> Generator[AsyncMock]: mock_client().session = mock_session mock_hotwater = MagicMock() + mock_hotwater.enable_holiday_mode = AsyncMock(return_value=True) + mock_hotwater.disable_holiday_mode = AsyncMock(return_value=True) mock_hotwater.get_water_heater = AsyncMock(return_value=mock_water_heater) mock_hotwater.set_profile = AsyncMock(return_value=True) mock_hotwater.set_v40_min = AsyncMock(return_value=True) diff --git a/tests/components/osoenergy/fixtures/water_heater.json b/tests/components/osoenergy/fixtures/water_heater.json index 82bdafb5d8a..4c2b7abbb41 100644 --- a/tests/components/osoenergy/fixtures/water_heater.json +++ b/tests/components/osoenergy/fixtures/water_heater.json @@ -16,5 +16,6 @@ "profile": [ 10, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 60 - ] + ], + "isInPowerSave": false } diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 18c434d133b..208fd3b2aa3 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -31,7 +31,7 @@ 'platform': 'osoenergy', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'osoenergy_water_heater', 'unit_of_measurement': None, @@ -40,11 +40,12 @@ # name: test_water_heater[water_heater.test_device-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'away_mode': 'off', 'current_temperature': 60, 'friendly_name': 'TEST DEVICE', 'max_temp': 75, 'min_temp': 10, - 'supported_features': , + 'supported_features': , 'target_temp_high': 63, 'target_temp_low': 57, 'temperature': 60, diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index fd27975c938..270fc3c58f0 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -14,7 +14,9 @@ from homeassistant.components.osoenergy.water_heater import ( SERVICE_SET_V40MIN, ) from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, SERVICE_SET_TEMPERATURE, ) from homeassistant.config_entries import ConfigEntry @@ -274,3 +276,37 @@ async def test_oso_turn_off( ) mock_osoenergy_client().hotwater.turn_off.assert_called_once_with(ANY, False) + + +async def test_turn_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode on.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "on"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with(ANY) + + +async def test_turn_away_mode_off( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test turning the heater away mode off.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + {ATTR_ENTITY_ID: "water_heater.test_device", ATTR_AWAY_MODE: "off"}, + blocking=True, + ) + + mock_osoenergy_client().hotwater.disable_holiday_mode.assert_called_once_with(ANY) From 8fc8220924f318bcd49468a6e9be2e84b0272daf Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 28 Jul 2025 10:06:15 -0400 Subject: [PATCH 0692/1118] Teach Hydrawise to auto-add/remove devices (#149547) Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/binary_sensor.py | 57 ++++++---- homeassistant/components/hydrawise/const.py | 1 + .../components/hydrawise/coordinator.py | 92 ++++++++++++++- homeassistant/components/hydrawise/entity.py | 6 +- homeassistant/components/hydrawise/sensor.py | 83 +++++++++----- homeassistant/components/hydrawise/switch.py | 23 ++-- homeassistant/components/hydrawise/valve.py | 22 +++- tests/components/hydrawise/test_init.py | 106 +++++++++++++++++- 8 files changed, 321 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 45537a2cc73..f2177d2144a 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime -from pydrawise import Zone +from pydrawise import Controller, Zone import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -81,31 +81,46 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise binary_sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseBinarySensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseBinarySensor(coordinators.main, description, controller) - for description in CONTROLLER_BINARY_SENSORS - ) - entities.extend( - HydrawiseBinarySensor( - coordinators.main, - description, - controller, - sensor_id=sensor.id, + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseBinarySensor] = [] + for controller in controllers: + entities.extend( + HydrawiseBinarySensor(coordinators.main, description, controller) + for description in CONTROLLER_BINARY_SENSORS ) - for sensor in controller.sensors - for description in RAIN_SENSOR_BINARY_SENSOR - if "rain sensor" in sensor.model.name.lower() - ) - entities.extend( + entities.extend( + HydrawiseBinarySensor( + coordinators.main, + description, + controller, + sensor_id=sensor.id, + ) + for sensor in controller.sensors + for description in RAIN_SENSOR_BINARY_SENSOR + if "rain sensor" in sensor.model.name.lower() + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( HydrawiseZoneBinarySensor( coordinators.main, description, controller, zone_id=zone.id ) - for zone in controller.zones + for zone, controller in zones for description in ZONE_BINARY_SENSORS ) - async_add_entities(entities) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_RESUME, None, "resume") platform.async_register_entity_service( diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index beaf450a586..502fd14cfbd 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -13,6 +13,7 @@ DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" +MODEL_ZONE = "Zone" MAIN_SCAN_INTERVAL = timedelta(minutes=5) WATER_USE_SCAN_INTERVAL = timedelta(minutes=60) diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 15d286801f9..308ffc23e36 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,17 +2,26 @@ from __future__ import annotations +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import now -from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL +from .const import ( + DOMAIN, + LOGGER, + MAIN_SCAN_INTERVAL, + MODEL_ZONE, + WATER_USE_SCAN_INTERVAL, +) type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators] @@ -24,6 +33,7 @@ class HydrawiseData: user: User controllers: dict[int, Controller] = field(default_factory=dict) zones: dict[int, Zone] = field(default_factory=dict) + zone_id_to_controller: dict[int, Controller] = field(default_factory=dict) sensors: dict[int, Sensor] = field(default_factory=dict) daily_water_summary: dict[int, ControllerWaterUseSummary] = field( default_factory=dict @@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): update_interval=MAIN_SCAN_INTERVAL, ) self.api = api + self.new_controllers_callbacks: list[ + Callable[[Iterable[Controller]], None] + ] = [] + self.new_zones_callbacks: list[ + Callable[[Iterable[tuple[Zone, Controller]]], None] + ] = [] + self.async_add_listener(self._add_remove_zones) async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" @@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): controller.zones = await self.api.get_zones(controller) for zone in controller.zones: data.zones[zone.id] = zone + data.zone_id_to_controller[zone.id] = controller for sensor in controller.sensors: data.sensors[sensor.id] = sensor return data + @callback + def _add_remove_zones(self) -> None: + """Add newly discovered zones and remove nonexistent ones.""" + if self.data is None: + # Likely a setup error; ignore. + # Despite what mypy thinks, this is still reachable. Without this check, + # the test_connect_retry test in test_init.py fails. + return # type: ignore[unreachable] + + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ) + previous_zones: set[str] = set() + previous_zones_by_id: dict[str, DeviceEntry] = {} + previous_controllers: set[str] = set() + previous_controllers_by_id: dict[str, DeviceEntry] = {} + for device in devices: + for domain, identifier in device.identifiers: + if domain == DOMAIN: + if device.model == MODEL_ZONE: + previous_zones.add(identifier) + previous_zones_by_id[identifier] = device + else: + previous_controllers.add(identifier) + previous_controllers_by_id[identifier] = device + continue + + current_zones = {str(zone_id) for zone_id in self.data.zones} + current_controllers = { + str(controller_id) for controller_id in self.data.controllers + } + + if removed_zones := previous_zones - current_zones: + LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) + for zone_id in removed_zones: + device_registry.async_update_device( + device_id=previous_zones_by_id[zone_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if removed_controllers := previous_controllers - current_controllers: + LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) + for controller_id in removed_controllers: + device_registry.async_update_device( + device_id=previous_controllers_by_id[controller_id].id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_controller_ids := current_controllers - previous_controllers: + LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids)) + new_controllers = [ + self.data.controllers[controller_id] + for controller_id in map(int, new_controller_ids) + ] + for new_controller_callback in self.new_controllers_callbacks: + new_controller_callback(new_controllers) + + if new_zone_ids := current_zones - previous_zones: + LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids)) + new_zones = [ + ( + self.data.zones[zone_id], + self.data.zone_id_to_controller[zone_id], + ) + for zone_id in map(int, new_zone_ids) + ] + for new_zone_callback in self.new_zones_callbacks: + new_zone_callback(new_zones) + class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """Data Update Coordinator for Hydrawise Water Use. diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 67dd6375b0e..58153d43634 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER, MODEL_ZONE from .coordinator import HydrawiseDataUpdateCoordinator @@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): identifiers={(DOMAIN, self._device_id)}, name=self.zone.name if zone_id is not None else controller.name, model=( - "Zone" if zone_id is not None else controller.hardware.model.description + MODEL_ZONE + if zone_id is not None + else controller.hardware.model.description ), manufacturer=MANUFACTURER, ) diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ce0bc5a0997..3a04a587bb4 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise.schema import ControllerWaterUseSummary +from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone from homeassistant.components.sensor import ( SensorDeviceClass, @@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription): def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary: - return sensor.coordinator.data.daily_water_summary[sensor.controller.id] + return sensor.coordinator.data.daily_water_summary.get( + sensor.controller.id, ControllerWaterUseSummary() + ) WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( @@ -133,44 +135,65 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise sensor platform.""" coordinators = config_entry.runtime_data - entities: list[HydrawiseSensor] = [] - for controller in coordinators.main.data.controllers.values(): - entities.extend( - HydrawiseSensor(coordinators.water_use, description, controller) - for description in WATER_USE_CONTROLLER_SENSORS + + def _has_flow_sensor(controller: Controller) -> bool: + daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get( + controller.id, ControllerWaterUseSummary() ) - entities.extend( - HydrawiseSensor( - coordinators.water_use, description, controller, zone_id=zone.id - ) - for zone in controller.zones - for description in WATER_USE_ZONE_SENSORS - ) - entities.extend( - HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id) - for zone in controller.zones - for description in ZONE_SENSORS - ) - if ( - coordinators.water_use.data.daily_water_summary[controller.id].total_use - is not None - ): - # we have a flow sensor for this controller + return daily_water_use_summary.total_use is not None + + def _add_new_controllers(controllers: Iterable[Controller]) -> None: + entities: list[HydrawiseSensor] = [] + for controller in controllers: entities.extend( HydrawiseSensor(coordinators.water_use, description, controller) - for description in FLOW_CONTROLLER_SENSORS + for description in WATER_USE_CONTROLLER_SENSORS ) - entities.extend( + if _has_flow_sensor(controller): + entities.extend( + HydrawiseSensor(coordinators.water_use, description, controller) + for description in FLOW_CONTROLLER_SENSORS + ) + async_add_entities(entities) + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + [ + HydrawiseSensor( + coordinators.water_use, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in WATER_USE_ZONE_SENSORS + ] + + [ + HydrawiseSensor( + coordinators.main, description, controller, zone_id=zone.id + ) + for zone, controller in zones + for description in ZONE_SENSORS + ] + + [ HydrawiseSensor( coordinators.water_use, description, controller, zone_id=zone.id, ) - for zone in controller.zones + for zone, controller in zones for description in FLOW_ZONE_SENSORS - ) - async_add_entities(entities) + if _has_flow_sensor(controller) + ] + ) + + _add_new_controllers(coordinators.main.data.controllers.values()) + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] + ) + coordinators.main.new_controllers_callbacks.append(_add_new_controllers) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSensor(HydrawiseEntity, SensorEntity): diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 7a77f27265b..238e249e1f6 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import HydrawiseBase, Zone +from pydrawise import Controller, HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -66,12 +66,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise switch platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in SWITCH_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in SWITCH_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 85a91c807b2..56dd56e7d21 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any -from pydrawise.schema import Zone +from pydrawise.schema import Controller, Zone from homeassistant.components.valve import ( ValveDeviceClass, @@ -33,12 +34,21 @@ async def async_setup_entry( ) -> None: """Set up the Hydrawise valve platform.""" coordinators = config_entry.runtime_data - async_add_entities( - HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) - for controller in coordinators.main.data.controllers.values() - for zone in controller.zones - for description in VALVE_TYPES + + def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None: + async_add_entities( + HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id) + for zone, controller in zones + for description in VALVE_TYPES + ) + + _add_new_zones( + [ + (zone, coordinators.main.data.zone_id_to_controller[zone.id]) + for zone in coordinators.main.data.zones.values() + ] ) + coordinators.main.new_zones_callbacks.append(_add_new_zones) class HydrawiseValve(HydrawiseEntity, ValveEntity): diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 8ec3c3da648..31e86589543 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,13 +1,19 @@ """Tests for the Hydrawise integration.""" +from copy import deepcopy from unittest.mock import AsyncMock from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller, User, Zone +from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_connect_retry( @@ -32,3 +38,101 @@ async def test_update_version( # Make sure reauth flow has been initiated assert any(mock_config_entry_legacy.async_get_active_flows(hass, {"reauth"})) + + +async def test_auto_add_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are auto-added to the device registry.""" + device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller.id))} + ) + assert device is not None + for zone in zones: + zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert zone_device is not None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 1 controller + 2 zones + assert len(all_devices) == 3 + + controller2 = deepcopy(controller) + controller2.id += 10 + controller2.name += " 2" + controller2.sensors = [] + + zones2 = deepcopy(zones) + for zone in zones2: + zone.id += 10 + zone.name += " 2" + + user.controllers = [controller, controller2] + mock_pydrawise.get_zones.side_effect = [zones, zones2] + + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + new_controller_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(controller2.id))} + ) + assert new_controller_device is not None + for zone in zones2: + new_zone_device = device_registry.async_get_device( + identifiers={(DOMAIN, str(zone.id))} + ) + assert new_zone_device is not None + + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + # 2 controllers + 4 zones + assert len(all_devices) == 6 + + +async def test_auto_remove_devices( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: MockConfigEntry, + user: User, + controller: Controller, + zones: list[Zone], + freezer: FrozenDateTimeFactory, +) -> None: + """Test old devices are auto-removed from the device registry.""" + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is not None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is not None + + user.controllers = [] + # Make the coordinator refresh data. + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, str(controller.id))}) + is None + ) + for zone in zones: + device = device_registry.async_get_device(identifiers={(DOMAIN, str(zone.id))}) + assert device is None + all_devices = dr.async_entries_for_config_entry( + device_registry, mock_added_config_entry.entry_id + ) + assert len(all_devices) == 0 From 96529ec245b2969891ea7c256d2c8657b0e91775 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 28 Jul 2025 16:12:53 +0200 Subject: [PATCH 0693/1118] Add Reolink pre-recording entities (#149522) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- homeassistant/components/reolink/icons.json | 12 +++++++ homeassistant/components/reolink/number.py | 34 ++++++++++++++++++- homeassistant/components/reolink/select.py | 14 ++++++++ homeassistant/components/reolink/strings.json | 12 +++++++ homeassistant/components/reolink/switch.py | 9 +++++ .../reolink/snapshots/test_diagnostics.ambr | 4 +++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 0c9831af2a8..597a3372400 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -300,6 +300,12 @@ }, "image_hue": { "default": "mdi:image-edit" + }, + "pre_record_time": { + "default": "mdi:history" + }, + "pre_record_battery_stop": { + "default": "mdi:history" } }, "select": { @@ -390,6 +396,9 @@ "packing_time": { "default": "mdi:record-rec" }, + "pre_record_fps": { + "default": "mdi:history" + }, "post_rec_time": { "default": "mdi:record-rec" } @@ -470,6 +479,9 @@ "manual_record": { "default": "mdi:record-rec" }, + "pre_record": { + "default": "mdi:history" + }, "hub_ringtone_on_event": { "default": "mdi:music-note" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 2de2468ca3d..d0222b0cffb 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -542,6 +542,38 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.image_hue(ch), method=lambda api, ch, value: api.set_image(ch, hue=int(value)), ), + ReolinkNumberEntityDescription( + key="pre_record_time", + cmd_key="594", + translation_key="pre_record_time", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=2, + native_max_value=10, + native_unit_of_measurement=UnitOfTime.SECONDS, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_time(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="pre_record_battery_stop", + cmd_key="594", + translation_key="pre_record_battery_stop", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=10, + native_max_value=80, + native_unit_of_measurement=PERCENTAGE, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_battery_stop(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, battery_stop=int(value) + ), + ), ) SMART_AI_NUMBER_ENTITIES = ( diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index d55cf9386f9..242ea784cd9 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -250,6 +250,20 @@ SELECT_ENTITIES = ( value=lambda api, ch: str(api.bit_rate(ch, "sub")), method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"), ), + ReolinkSelectEntityDescription( + key="pre_record_fps", + cmd_key="594", + translation_key="pre_record_fps", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + unit_of_measurement=UnitOfFrequency.HERTZ, + get_options=["1", "2", "5"], + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: str(api.baichuan.pre_record_fps(ch)), + method=lambda api, ch, value: api.baichuan.set_pre_recording( + ch, fps=int(value) + ), + ), ReolinkSelectEntityDescription( key="post_rec_time", cmd_key="GetRec", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1b155af6a4d..7e8bf94eeae 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -654,6 +654,12 @@ }, "image_hue": { "name": "Image hue" + }, + "pre_record_time": { + "name": "Pre-recording time" + }, + "pre_record_battery_stop": { + "name": "Pre-recording stop battery level" } }, "select": { @@ -858,6 +864,9 @@ "packing_time": { "name": "Recording packing time" }, + "pre_record_fps": { + "name": "Pre-recording frame rate" + }, "post_rec_time": { "name": "Post-recording time" } @@ -946,6 +955,9 @@ "manual_record": { "name": "Manual record" }, + "pre_record": { + "name": "Pre-recording" + }, "hub_ringtone_on_event": { "name": "Hub ringtone on event" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 47b14f7f4ad..00934bc9777 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -169,6 +169,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.manual_record_enabled(ch), method=lambda api, ch, value: api.set_manual_record(ch, value), ), + ReolinkSwitchEntityDescription( + key="pre_record", + cmd_key="594", + translation_key="pre_record", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "pre_record"), + value=lambda api, ch: api.baichuan.pre_record_enabled(ch), + method=lambda api, ch, value: api.baichuan.set_pre_recording(ch, enabled=value), + ), ReolinkSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 25a9dc299aa..c2b059d658b 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -77,6 +77,10 @@ '0': 1, 'null': 1, }), + '594': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, From 9a364ec72914bb7e226394d8f8bd32c0dc30cd39 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 28 Jul 2025 16:13:39 +0200 Subject: [PATCH 0694/1118] Fix Z-Wave removal of devices when connected to unknown controller (#149339) --- homeassistant/components/zwave_js/__init__.py | 121 ++++++++++-------- homeassistant/components/zwave_js/repairs.py | 1 + tests/components/zwave_js/test_config_flow.py | 50 ++++++-- tests/components/zwave_js/test_init.py | 8 +- tests/components/zwave_js/test_repairs.py | 33 ++++- 5 files changed, 140 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 982525be778..d754419c94c 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -277,39 +277,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> b # and we'll handle the clean up below. await driver_events.setup(driver) - if (old_unique_id := entry.unique_id) is not None and old_unique_id != ( - new_unique_id := str(driver.controller.home_id) - ): - device_registry = dr.async_get(hass) - controller_model = "Unknown model" - if ( - (own_node := driver.controller.own_node) - and ( - controller_device_entry := device_registry.async_get_device( - identifiers={get_device_id(driver, own_node)} - ) - ) - and (model := controller_device_entry.model) - ): - controller_model = model - async_create_issue( - hass, - DOMAIN, - f"migrate_unique_id.{entry.entry_id}", - data={ - "config_entry_id": entry.entry_id, - "config_entry_title": entry.title, - "controller_model": controller_model, - "new_unique_id": new_unique_id, - "old_unique_id": old_unique_id, - }, - is_fixable=True, - severity=IssueSeverity.ERROR, - translation_key="migrate_unique_id", - ) - else: - async_delete_issue(hass, DOMAIN, f"migrate_unique_id.{entry.entry_id}") - # If the listen task is already failed, we need to raise ConfigEntryNotReady if listen_task.done(): listen_error, error_message = _get_listen_task_error(listen_task) @@ -387,28 +354,6 @@ class DriverEvents: self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) ) - # Check for nodes that no longer exist and remove them - stored_devices = dr.async_entries_for_config_entry( - self.dev_reg, self.config_entry.entry_id - ) - known_devices = [ - self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) - for node in controller.nodes.values() - ] - provisioned_devices = [ - self.dev_reg.async_get(entry.additional_properties["device_id"]) - for entry in await controller.async_get_provisioning_entries() - if entry.additional_properties - and "device_id" in entry.additional_properties - ] - - # Devices that are in the device registry that are not known by the controller - # can be removed - if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): - for device in stored_devices: - if device not in known_devices and device not in provisioned_devices: - self.dev_reg.async_remove_device(device.id) - # run discovery on controller node if controller.own_node: await self.controller_events.async_on_node_added(controller.own_node) @@ -443,6 +388,72 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) + if ( + old_unique_id := self.config_entry.unique_id + ) is not None and old_unique_id != ( + new_unique_id := str(driver.controller.home_id) + ): + device_registry = dr.async_get(self.hass) + controller_model = "Unknown model" + if ( + (own_node := driver.controller.own_node) + and ( + controller_device_entry := device_registry.async_get_device( + identifiers={get_device_id(driver, own_node)} + ) + ) + and (model := controller_device_entry.model) + ): + controller_model = model + + # Do not clean up old stale devices if an unknown controller is connected. + data = {**self.config_entry.data, CONF_KEEP_OLD_DEVICES: True} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_create_issue( + self.hass, + DOMAIN, + f"migrate_unique_id.{self.config_entry.entry_id}", + data={ + "config_entry_id": self.config_entry.entry_id, + "config_entry_title": self.config_entry.title, + "controller_model": controller_model, + "new_unique_id": new_unique_id, + "old_unique_id": old_unique_id, + }, + is_fixable=True, + severity=IssueSeverity.ERROR, + translation_key="migrate_unique_id", + ) + else: + data = self.config_entry.data.copy() + data.pop(CONF_KEEP_OLD_DEVICES, None) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) + async_delete_issue( + self.hass, DOMAIN, f"migrate_unique_id.{self.config_entry.entry_id}" + ) + + # Check for nodes that no longer exist and remove them + stored_devices = dr.async_entries_for_config_entry( + self.dev_reg, self.config_entry.entry_id + ) + known_devices = [ + self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) + for node in controller.nodes.values() + ] + provisioned_devices = [ + self.dev_reg.async_get(entry.additional_properties["device_id"]) + for entry in await controller.async_get_provisioning_entries() + if entry.additional_properties + and "device_id" in entry.additional_properties + ] + + # Devices that are in the device registry that are not known by the controller + # can be removed + if not self.config_entry.data.get(CONF_KEEP_OLD_DEVICES): + for device in stored_devices: + if device not in known_devices and device not in provisioned_devices: + self.dev_reg.async_remove_device(device.id) + class ControllerEvents: """Represent controller events. diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index f1deb91d869..072a330a7bd 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -90,6 +90,7 @@ class MigrateUniqueIDFlow(RepairsFlow): config_entry, unique_id=self.description_placeholders["new_unique_id"], ) + self.hass.config_entries.async_schedule_reload(config_entry.entry_id) return self.async_create_entry(data={}) return self.async_show_form( diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c708b1c9d66..15ec6959caf 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -883,9 +883,9 @@ async def test_usb_discovery_migration( addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -893,6 +893,11 @@ async def test_usb_discovery_migration( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -914,6 +919,7 @@ async def test_usb_discovery_migration( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) @@ -967,7 +973,7 @@ async def test_usb_discovery_migration( assert restart_addon.call_args == call("core_zwave_js") version_info = get_server_version.return_value - version_info.home_id = 5678 + version_info.home_id = 3245146787 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -991,7 +997,7 @@ async def test_usb_discovery_migration( assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True assert "keep_old_devices" not in entry.data - assert entry.unique_id == "5678" + assert entry.unique_id == "3245146787" @pytest.mark.usefixtures("supervisor", "addon_running") @@ -1008,9 +1014,9 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -1018,6 +1024,11 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -1039,6 +1050,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -1113,7 +1125,8 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == USB_DISCOVERY_INFO.device assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert entry.unique_id == "1234" + assert "keep_old_devices" in entry.data @pytest.mark.usefixtures("supervisor", "addon_installed") @@ -3011,6 +3024,7 @@ async def test_reconfigure_different_device( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3164,6 +3178,7 @@ async def test_reconfigure_addon_restart_failed( entry = integration data = {**entry.data, **entry_data} hass.config_entries.async_update_entry(entry, data=data, unique_id="1234") + client.driver.controller.data["homeId"] = 1234 assert entry.data["url"] == "ws://test.org" @@ -3554,10 +3569,12 @@ async def test_reconfigure_migrate_low_sdk_version( ( "restore_server_version_side_effect", "final_unique_id", + "keep_old_devices", + "device_entry_count", ), [ - (None, "3245146787"), - (aiohttp.ClientError("Boom"), "5678"), + (None, "3245146787", False, 2), + (aiohttp.ClientError("Boom"), "5678", True, 4), ], ) async def test_reconfigure_migrate_with_addon( @@ -3572,12 +3589,15 @@ async def test_reconfigure_migrate_with_addon( get_server_version: AsyncMock, restore_server_version_side_effect: Exception | None, final_unique_id: str, + keep_old_devices: bool, + device_entry_count: int, ) -> None: """Test migration flow with add-on.""" version_info = get_server_version.return_value entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, data={ @@ -3745,10 +3765,10 @@ async def test_reconfigure_migrate_with_addon( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert ("keep_old_devices" in entry.data) is keep_old_devices assert entry.unique_id == final_unique_id - assert len(device_registry.devices) == 2 + assert len(device_registry.devices) == device_entry_count controller_device_id_ext = ( f"{controller_device_id}-{controller_node.manufacturer_id}:" f"{controller_node.product_type}:{controller_node.product_id}" @@ -3780,9 +3800,10 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( """Test migration flow with driver ready timeout after nvm restore.""" entry = integration assert client.connect.call_count == 1 + assert client.driver.controller.home_id == 3245146787 + assert entry.unique_id == "3245146787" hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -3790,6 +3811,11 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( }, ) + async def mock_restart_addon(addon_slug: str) -> None: + client.driver.controller.data["homeId"] = 1234 + + restart_addon.side_effect = mock_restart_addon + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -3811,6 +3837,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( "nvm restore progress", {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, ) + client.driver.controller.data["homeId"] = 3245146787 client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) @@ -3894,7 +3921,8 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert entry.data["url"] == "ws://host1:3001" assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True - assert "keep_old_devices" not in entry.data + assert "keep_old_devices" in entry.data + assert entry.unique_id == "1234" async def test_reconfigure_migrate_backup_failure( diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 930f27e73f0..d9b3f392dd6 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -2070,12 +2070,8 @@ async def test_server_logging( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 2 - assert client.async_send_command.call_args_list[0][0][0] == { - "command": "controller.get_provisioning_entries", - } - assert client.async_send_command.call_args_list[1][0][0] == { - "command": "controller.get_provisioning_entry", - "dskOrNodeId": 1, + assert "driver.update_log_config" not in { + call[0][0]["command"] for call in client.async_send_command.call_args_list } assert not client.enable_server_logging.called assert not client.disable_server_logging.called diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d783e3deaba..d47fd771127 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -1,13 +1,14 @@ """Test the Z-Wave JS repairs module.""" from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir @@ -276,8 +277,12 @@ async def test_migrate_unique_id( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + client: MagicMock, + multisensor_6: Node, ) -> None: """Test the migrate unique id flow.""" + node = multisensor_6 old_unique_id = "123456789" config_entry = MockConfigEntry( domain=DOMAIN, @@ -289,8 +294,27 @@ async def test_migrate_unique_id( ) config_entry.add_to_hass(hass) + # Remove the node from the current controller's known nodes. + client.driver.controller.nodes.pop(node.node_id) + + # Create a device entry for the node connected to the old controller. + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{old_unique_id}-{node.node_id}")}, + name="Node connected to old controller", + ) + assert device_entry.name == "Node connected to old controller" + await hass.config_entries.async_setup(config_entry.entry_id) + assert CONF_KEEP_OLD_DEVICES in config_entry.data + assert config_entry.data[CONF_KEEP_OLD_DEVICES] is True + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 2 + assert device_entry.id in {device.id for device in stored_devices} + await async_process_repairs_platforms(hass) ws_client = await hass_ws_client(hass) http_client = await hass_client() @@ -317,6 +341,13 @@ async def test_migrate_unique_id( # Apply fix data = await process_repair_fix_flow(http_client, flow_id) + await hass.async_block_till_done() + + stored_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(stored_devices) == 1 + assert device_entry.id not in {device.id for device in stored_devices} assert data["type"] == "create_entry" assert config_entry.unique_id == "3245146787" From ee2cf961f6624786af7e4e6d25b9b02a72e5792e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:17:09 -0400 Subject: [PATCH 0695/1118] Add assumed optimistic functionality to lock platform (#149397) --- homeassistant/components/template/lock.py | 57 ++++++++++++----------- tests/components/template/test_lock.py | 49 +++++++++++++++++++ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 848469b0ca4..e89f95734d1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -29,12 +29,13 @@ from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_PICTURE, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -54,18 +55,18 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_CODE_FORMAT): cv.template, - vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +LOCK_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CODE_FORMAT): cv.template, + vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, + } +) + +LOCK_YAML_SCHEMA = LOCK_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema ) PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( @@ -105,6 +106,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. @@ -112,12 +114,9 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Initialize the features.""" self._state: LockState | None = None - self._state_template = config.get(CONF_STATE) self._code_format_template = config.get(CONF_CODE_FORMAT) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None - self._optimistic = config.get(CONF_OPTIMISTIC) - self._attr_assumed_state = bool(self._optimistic) def _iterate_scripts( self, config: dict[str, Any] @@ -211,7 +210,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.LOCKED self.async_write_ha_state() @@ -229,7 +228,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.UNLOCKED self.async_write_ha_state() @@ -247,7 +246,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): # template before processing the action. self._raise_template_error_if_available() - if self._optimistic: + if self._attr_assumed_state: self._state = LockState.OPEN self.async_write_ha_state() @@ -310,11 +309,13 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): @callback def _async_setup_templates(self) -> None: """Set up templates.""" - if TYPE_CHECKING: - assert self._state_template is not None - self.add_template_attribute( - "_state", self._state_template, None, self._update_state - ) + if self._template is not None: + self.add_template_attribute( + "_state", + self._template, + None, + self._update_state, + ) if self._code_format_template: self.add_template_attribute( "_code_format_template", @@ -329,7 +330,6 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): """Lock entity based on trigger data.""" domain = LOCK_DOMAIN - extra_template_keys = (CONF_STATE,) def __init__( self, @@ -343,6 +343,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + if isinstance(config.get(CONF_CODE_FORMAT), template.Template): self._to_render_simple.append(CONF_CODE_FORMAT) self._parse_result.add(CONF_CODE_FORMAT) @@ -371,9 +374,9 @@ class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): updater(rendered) write_ha_state = True - if not self._optimistic: + if not self._attr_assumed_state: write_ha_state = True - elif self._optimistic and len(self._rendered) > 0: + elif self._attr_assumed_state and len(self._rendered) > 0: # In case any non optimistic template write_ha_state = True diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index cbee71824ae..457c5b7bf5c 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -1137,3 +1137,52 @@ async def test_emtpy_action_config(hass: HomeAssistant) -> None: state = hass.states.get("lock.test_template_lock") assert state.state == LockState.LOCKED + + +@pytest.mark.parametrize( + ("count", "lock_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "lock": [], + "unlock": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_lock") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED + + # Ensure Trigger template entities update. + hass.states.async_set(TEST_STATE_ENTITY_ID, "anything") + await hass.async_block_till_done() + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == LockState.UNLOCKED From 1895db0ddd0bc205589d7ab3aa3d578840b9eecb Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:17:39 -0400 Subject: [PATCH 0696/1118] Add optimistic option to switch yaml (#149402) --- homeassistant/components/template/switch.py | 114 +++++++++----------- tests/components/template/test_switch.py | 60 ++++++++++- 2 files changed, 110 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index f5835f2d478..cc0fd4c7ad2 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -38,6 +38,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .entity import AbstractTemplateEntity from .helpers import ( async_setup_template_entry, async_setup_template_platform, @@ -46,6 +47,7 @@ from .helpers import ( from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -68,8 +70,8 @@ SWITCH_COMMON_SCHEMA = vol.Schema( ) SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( - make_template_entity_common_modern_schema(DEFAULT_NAME).schema -) + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -145,11 +147,38 @@ def async_create_preview_switch( ) -class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): +class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity): + """Representation of a template switch features.""" + + _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Fire the on action.""" + if on_script := self._action_scripts.get(CONF_TURN_ON): + await self.async_run_script(on_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Fire the off action.""" + if off_script := self._action_scripts.get(CONF_TURN_OFF): + await self.async_run_script(off_script, context=self._context) + if self._attr_assumed_state: + self._attr_is_on = False + self.async_write_ha_state() + + +class StateSwitchEntity(TemplateEntity, AbstractTemplateSwitch): """Representation of a Template switch.""" _attr_should_poll = False - _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -158,12 +187,12 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config, unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateSwitch.__init__(self, config) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_STATE) # Scripts can be an empty list, therefore we need to check for None if (on_action := config.get(CONF_TURN_ON)) is not None: @@ -171,25 +200,22 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): 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 - self._attr_assumed_state = self._template is None - @callback def _update_state(self, result): super()._update_state(result) if isinstance(result, TemplateError): - self._state = None + self._attr_is_on = None return if isinstance(result, bool): - self._state = result + self._attr_is_on = result return if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON) + self._attr_is_on = result.lower() in ("true", STATE_ON) return - self._state = False + self._attr_is_on = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -197,7 +223,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): # restore state after startup await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._state = state.state == STATE_ON + self._attr_is_on = state.state == STATE_ON await super().async_added_to_hass() @callback @@ -205,37 +231,15 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_is_on", self._template, None, self._update_state ) super()._async_setup_templates() - @property - def is_on(self) -> bool | None: - """Return true if device is on.""" - return self._state - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._state = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._state = False - self.async_write_ha_state() - - -class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): +class TriggerSwitchEntity(TriggerEntity, AbstractTemplateSwitch): """Switch entity based on trigger data.""" - _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -245,17 +249,16 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): config: ConfigType, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSwitch.__init__(self, config) name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) if off_action := config.get(CONF_TURN_OFF): self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) - self._attr_assumed_state = self._template is None - if not self._attr_assumed_state: + if CONF_STATE in config: self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) @@ -281,28 +284,15 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self.async_write_ha_state() return - if not self._attr_assumed_state: - raw = self._rendered.get(CONF_STATE) - self._attr_is_on = template.result_as_boolean(raw) + write_ha_state = False + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_is_on = template.result_as_boolean(state) + write_ha_state = True - self.async_write_ha_state() - elif self._attr_assumed_state and len(self._rendered) > 0: + elif len(self._rendered) > 0: # In case name, icon, or friendly name have a template but # states does not - self.async_write_ha_state() + write_ha_state = True - async def async_turn_on(self, **kwargs: Any) -> None: - """Fire the on action.""" - if on_script := self._action_scripts.get(CONF_TURN_ON): - await self.async_run_script(on_script, context=self._context) - if self._template is None: - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Fire the off action.""" - if off_script := self._action_scripts.get(CONF_TURN_OFF): - await self.async_run_script(off_script, context=self._context) - if self._template is None: - self._attr_is_on = False + if write_ha_state: self.async_write_ha_state() diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2e2fb5e8093..a32f1df4c76 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -34,8 +34,13 @@ TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "switch.test_state" TEST_EVENT_TRIGGER = { - "trigger": {"platform": "event", "event_type": "test_event"}, - "variables": {"type": "{{ trigger.event.data.type }}"}, + "triggers": [ + {"trigger": "event", "event_type": "test_event"}, + {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID]}, + ], + "variables": { + "type": "{{ trigger.event.data.type if trigger.event is defined else trigger.entity_id }}" + }, "action": [{"event": "action_event", "event_data": {"type": "{{ type }}"}}], } @@ -1211,3 +1216,54 @@ async def test_empty_action_config(hass: HomeAssistant, setup_switch) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "switch_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('switch.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.usefixtures("setup_switch") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + switch.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From b3862591ea217a1659f795ec934840ffe01ec929 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:18:37 -0400 Subject: [PATCH 0697/1118] Add optimism to vacuum platform (#149425) --- homeassistant/components/template/vacuum.py | 70 +++++++------ tests/components/template/test_vacuum.py | 108 ++++++++++++++++++++ 2 files changed, 149 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 5ff99020f0d..67f0f780388 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -44,6 +44,7 @@ from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -76,24 +77,26 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, - vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, - vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, - vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) +VACUUM_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BATTERY_LEVEL): cv.template, + vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, + vol.Optional(CONF_FAN_SPEED): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, + vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, + vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, + } ) +VACUUM_YAML_SCHEMA = VACUUM_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) + VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( @@ -147,16 +150,15 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL) self._fan_speed_template = config.get(CONF_FAN_SPEED) - self._state = None self._battery_level = None self._attr_fan_speed = None @@ -185,17 +187,12 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if (action_config := config.get(action_id)) is not None: yield (action_id, action_config, supported_feature) - @property - def activity(self) -> VacuumActivity | None: - """Return the status of the vacuum cleaner.""" - return self._state - def _handle_state(self, result: Any) -> None: # Validate state if result in _VALID_STATES: - self._state = result + self._attr_activity = result elif result == STATE_UNKNOWN: - self._state = None + self._attr_activity = None else: _LOGGER.error( "Received invalid vacuum state: %s for entity %s. Expected: %s", @@ -203,31 +200,46 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self.entity_id, ", ".join(_VALID_STATES), ) - self._state = None + self._attr_activity = None async def async_start(self) -> None: """Start or resume the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() await self.async_run_script( self._action_scripts[SERVICE_START], context=self._context ) async def async_pause(self) -> None: """Pause the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.PAUSED + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_PAUSE): await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.IDLE + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_STOP): await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.RETURNING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" + if self._attr_assumed_state: + self._attr_activity = VacuumActivity.CLEANING + self.async_write_ha_state() if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): await self.async_run_script(script, context=self._context) @@ -274,7 +286,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): if isinstance(fan_speed, TemplateError): # This is legacy behavior self._attr_fan_speed = None - self._state = None + self._attr_activity = None return if fan_speed in self._attr_fan_speed_list: @@ -320,7 +332,7 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """Set up templates.""" if self._template is not None: self.add_template_attribute( - "_state", self._template, None, self._update_state + "_attr_activity", self._template, None, self._update_state ) if self._fan_speed_template is not None: self.add_template_attribute( @@ -344,7 +356,7 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): super()._update_state(result) if isinstance(result, TemplateError): # This is legacy behavior - self._state = STATE_UNKNOWN + self._attr_activity = None if not self._availability_template: self._attr_available = True return diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index ae65823309a..540b4eccd3b 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -1153,3 +1153,111 @@ async def test_empty_action_config( assert state.attributes["supported_features"] == ( VacuumEntityFeature.STATE | VacuumEntityFeature.START | supported_features ) + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + {"name": TEST_OBJECT_ID, "start": [], **TEMPLATE_VACUUM_ACTIONS}, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_assumed_optimistic( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test assumed optimistic.""" + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + +@pytest.mark.parametrize( + ("count", "vacuum_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('sensor.test_state') }}", + "start": [], + **TEMPLATE_VACUUM_ACTIONS, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.parametrize( + ("service", "expected"), + [ + (vacuum.SERVICE_START, VacuumActivity.CLEANING), + (vacuum.SERVICE_PAUSE, VacuumActivity.PAUSED), + (vacuum.SERVICE_STOP, VacuumActivity.IDLE), + (vacuum.SERVICE_RETURN_TO_BASE, VacuumActivity.RETURNING), + (vacuum.SERVICE_CLEAN_SPOT, VacuumActivity.CLEANING), + ], +) +@pytest.mark.usefixtures("setup_vacuum") +async def test_optimistic_option( + hass: HomeAssistant, + service: str, + expected: VacuumActivity, + calls: list[ServiceCall], +) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + await hass.services.async_call( + vacuum.DOMAIN, + service, + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.RETURNING) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == VacuumActivity.DOCKED From 978ee3870c0631fd17e089de05489c8e20da966a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:18:57 +0200 Subject: [PATCH 0698/1118] Add notify platform to PlayStation Network integration (#149557) --- .../playstation_network/__init__.py | 9 +- .../playstation_network/binary_sensor.py | 7 +- .../playstation_network/coordinator.py | 20 +++ .../playstation_network/diagnostics.py | 10 +- .../components/playstation_network/entity.py | 8 +- .../components/playstation_network/icons.json | 5 + .../components/playstation_network/image.py | 1 + .../components/playstation_network/notify.py | 126 +++++++++++++++++ .../components/playstation_network/sensor.py | 7 +- .../playstation_network/strings.json | 14 ++ .../playstation_network/conftest.py | 11 ++ .../snapshots/test_diagnostics.ambr | 12 +- .../snapshots/test_notify.ambr | 50 +++++++ .../playstation_network/test_notify.py | 127 ++++++++++++++++++ 14 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/playstation_network/notify.py create mode 100644 tests/components/playstation_network/snapshots/test_notify.ambr create mode 100644 tests/components/playstation_network/test_notify.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index be0eae961e0..bfa9de5d5cb 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_NPSSO from .coordinator import ( PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, PlaystationNetworkRuntimeData, PlaystationNetworkTrophyTitlesCoordinator, PlaystationNetworkUserDataCoordinator, @@ -18,6 +19,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.IMAGE, Platform.MEDIA_PLAYER, + Platform.NOTIFY, Platform.SENSOR, ] @@ -34,7 +36,12 @@ async def async_setup_entry( trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) - entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) + groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry) + await groups.async_config_entry_first_refresh() + + entry.runtime_data = PlaystationNetworkRuntimeData( + coordinator, trophy_titles, groups + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index 453cfb37347..89a752eff0e 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -63,6 +67,7 @@ class PlaystationNetworkBinarySensorEntity( """Representation of a PlayStation Network binary sensor entity.""" entity_description: PlaystationNetworkBinarySensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def is_on(self) -> bool: diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index fa00ac2c8ec..19153d1bb01 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -12,6 +12,7 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.group.group_datatypes import GroupDetails from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry @@ -33,6 +34,7 @@ class PlaystationNetworkRuntimeData: user_data: PlaystationNetworkUserDataCoordinator trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + groups: PlaystationNetworkGroupsUpdateCoordinator class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -120,3 +122,21 @@ class PlaystationNetworkTrophyTitlesCoordinator( ) await self.config_entry.runtime_data.user_data.async_request_refresh() return self.psn.trophy_titles + + +class PlaystationNetworkGroupsUpdateCoordinator( + PlayStationNetworkBaseCoordinator[dict[str, GroupDetails]] +): + """Groups data update coordinator for PSN.""" + + _update_interval = timedelta(hours=3) + + async def update_data(self) -> dict[str, GroupDetails]: + """Update groups data.""" + return await self.hass.async_add_executor_job( + lambda: { + group_info.group_id: group_info.get_group_information() + for group_info in self.psn.client.get_groups() + if not group_info.group_id.startswith("~") + } + ) diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 7b5c762db12..710760a015c 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -20,6 +20,11 @@ TO_REDACT = { "onlineId", "url", "username", + "onlineId", + "accountId", + "members", + "body", + "shareable_profile_link", } @@ -28,11 +33,12 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data.user_data - + groups = entry.runtime_data.groups return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ) + ), + "groups": async_redact_data(groups.data, TO_REDACT), } diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 660c77dc30f..ad7c52bdb39 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,11 +7,11 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkUserDataCoordinator +from .coordinator import PlayStationNetworkBaseCoordinator class PlaystationNetworkServiceEntity( - CoordinatorEntity[PlaystationNetworkUserDataCoordinator] + CoordinatorEntity[PlayStationNetworkBaseCoordinator] ): """Common entity class for PlayStationNetwork Service entities.""" @@ -19,7 +19,7 @@ class PlaystationNetworkServiceEntity( def __init__( self, - coordinator: PlaystationNetworkUserDataCoordinator, + coordinator: PlayStationNetworkBaseCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" @@ -32,7 +32,7 @@ class PlaystationNetworkServiceEntity( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, - name=coordinator.data.username, + name=coordinator.psn.user.online_id, entry_type=DeviceEntryType.SERVICE, manufacturer="Sony Interactive Entertainment", ) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2ea09823ca4..af2236bd126 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -51,6 +51,11 @@ "avatar": { "default": "mdi:account-circle" } + }, + "notify": { + "group_message": { + "default": "mdi:forum" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py index 8f9d19e3a55..b0195002c66 100644 --- a/homeassistant/components/playstation_network/image.py +++ b/homeassistant/components/playstation_network/image.py @@ -79,6 +79,7 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity """An image entity.""" entity_description: PlaystationNetworkImageEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator def __init__( self, diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py new file mode 100644 index 00000000000..872ad98a594 --- /dev/null +++ b/homeassistant/components/playstation_network/notify.py @@ -0,0 +1,126 @@ +"""Notify platform for PlayStation Network.""" + +from __future__ import annotations + +from enum import StrEnum + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 20 + + +class PlaystationNetworkNotify(StrEnum): + """PlayStation Network sensors.""" + + GROUP_MESSAGE = "group_message" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entity platform.""" + + coordinator = config_entry.runtime_data.groups + groups_added: set[str] = set() + entity_registry = er.async_get(hass) + + @callback + def add_entities() -> None: + nonlocal groups_added + + new_groups = set(coordinator.data.keys()) - groups_added + if new_groups: + async_add_entities( + PlaystationNetworkNotifyEntity(coordinator, group_id) + for group_id in new_groups + ) + groups_added |= new_groups + + deleted_groups = groups_added - set(coordinator.data.keys()) + for group_id in deleted_groups: + if entity_id := entity_registry.async_get_entity_id( + NOTIFY_DOMAIN, + DOMAIN, + f"{coordinator.config_entry.unique_id}_{group_id}", + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(add_entities) + add_entities() + + +class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity): + """Representation of a PlayStation Network notify entity.""" + + coordinator: PlaystationNetworkGroupsUpdateCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkGroupsUpdateCoordinator, + group_id: str, + ) -> None: + """Initialize a notification entity.""" + self.group = coordinator.psn.psn.group(group_id=group_id) + group_details = coordinator.data[group_id] + self.entity_description = NotifyEntityDescription( + key=group_id, + translation_key=PlaystationNetworkNotify.GROUP_MESSAGE, + translation_placeholders={ + "group_name": group_details["groupName"]["value"] + or ", ".join( + member["onlineId"] + for member in group_details["members"] + if member["accountId"] != coordinator.psn.user.account_id + ) + }, + ) + + super().__init__(coordinator, self.entity_description) + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + + try: + self.group.send_message(message) + except PSNAWPNotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="group_invalid", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except PSNAWPForbiddenError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except (PSNAWPServerError, PSNAWPClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders=dict(self.translation_placeholders), + ) from e diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index b17b4c04ab7..63cca074c3e 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -18,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -145,6 +149,7 @@ class PlaystationNetworkSensorEntity( """Representation of a PlayStation Network sensor entity.""" entity_description: PlaystationNetworkSensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index aaefdf51506..4fefc508ea2 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -50,6 +50,15 @@ }, "update_failed": { "message": "Data retrieval failed when trying to access the PlayStation Network." + }, + "group_invalid": { + "message": "Failed to send message to group {group_name}. The group is invalid or does not exist." + }, + "send_message_forbidden": { + "message": "Failed to send message to group {group_name}. You are not allowed to send messages to this group." + }, + "send_message_failed": { + "message": "Failed to send message to group {group_name}. Try again later." } }, "entity": { @@ -104,6 +113,11 @@ "avatar": { "name": "Avatar" } + }, + "notify": { + "group_message": { + "name": "Group: {group_name}" + } } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 77ec2377932..8480d7ecf5d 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models.group.group import Group from psnawp_api.models.trophies import ( PlatformType, TrophySet, @@ -159,6 +160,16 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.me.return_value.get_shareable_profile_link.return_value = { "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" } + group = MagicMock(spec=Group, group_id="test-groupid") + + group.get_group_information.return_value = { + "groupName": {"value": ""}, + "members": [ + {"onlineId": "PublicUniversalFriend", "accountId": "fren-psn-id"}, + {"onlineId": "testuser", "accountId": PSN_ID}, + ], + } + client.me.return_value.get_groups.return_value = [group] yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 0b7aa63fc03..894fa2d9084 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,9 +71,7 @@ 'PS5', 'PSVITA', ]), - 'shareable_profile_link': dict({ - 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', - }), + 'shareable_profile_link': '**REDACTED**', 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ @@ -88,5 +86,13 @@ }), 'username': '**REDACTED**', }), + 'groups': dict({ + 'test-groupid': dict({ + 'groupName': dict({ + 'value': '', + }), + 'members': '**REDACTED**', + }), + }), }) # --- diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr new file mode 100644 index 00000000000..60525925787 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + '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': 'Group: PublicUniversalFriend', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_test-groupid', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Group: PublicUniversalFriend', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py new file mode 100644 index 00000000000..ebaac37a09f --- /dev/null +++ b/tests/components/playstation_network/test_notify.py @@ -0,0 +1,127 @@ +"""Tests for the PlayStation Network notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from freezegun.api import freeze_time +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-07-28T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == "2025-07-28T00:00:00+00:00" + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") + + +@pytest.mark.parametrize( + "exception", + [PSNAWPClientError, PSNAWPForbiddenError, PSNAWPNotFoundError, PSNAWPServerError], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + mock_psnawpapi.group.return_value.send_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") From e8b8d310276e8a1a4068900e8790ee4580dde0e6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 28 Jul 2025 16:31:13 +0200 Subject: [PATCH 0699/1118] Make actions labels consistent for Template alarm control panel (#149574) --- homeassistant/components/template/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index e178b383a78..be91b27e485 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -17,13 +17,13 @@ "device_id": "[%key:common::config_flow::data::device%]", "value_template": "[%key:component::template::common::state%]", "name": "[%key:common::config_flow::data::name%]", - "disarm": "Disarm action", - "arm_away": "Arm away action", - "arm_custom_bypass": "Arm custom bypass action", - "arm_home": "Arm home action", - "arm_night": "Arm night action", - "arm_vacation": "Arm vacation action", - "trigger": "Trigger action", + "disarm": "Actions on disarm", + "arm_away": "Actions on arm away", + "arm_custom_bypass": "Actions on arm custom bypass", + "arm_home": "Actions on arm home", + "arm_night": "Actions on arm night", + "arm_vacation": "Actions on arm vacation", + "trigger": "Actions on trigger", "code_arm_required": "Code arm required", "code_format": "[%key:component::template::common::code_format%]" }, From 5ef17c8588b1e8c69218c2cfd7e3cc7e585bcf82 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 28 Jul 2025 16:32:56 +0200 Subject: [PATCH 0700/1118] Bump the required version of ruff to 0.12.1 (#149571) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b75b80f47dd..d15a93fd8bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -647,7 +647,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.11.0" +required-version = ">=0.12.1" [tool.ruff.lint] select = [ From d3f18c1678983cafb9776aff3577d5bfd0b2013e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 15:35:38 +0100 Subject: [PATCH 0701/1118] Add quality scale to ring manifest (#149406) --- homeassistant/components/ring/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 86758b26794..e7436e4d12d 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], + "quality_scale": "bronze", "requirements": ["ring-doorbell==0.9.13"] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index def20d9d4cc..1d6db8e1f7a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1890,7 +1890,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "rfxtrx", "rhasspy", "ridwell", - "ring", "ripple", "risco", "rituals_perfume_genie", From 49bd15718cfffc6fab3ca4d4f42d3adf6857986d Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:58:46 -0400 Subject: [PATCH 0702/1118] Add optimistic option to fan yaml (#149390) --- homeassistant/components/template/fan.py | 55 ++++++++++++------------ tests/components/template/test_fan.py | 48 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2d0d06f86a1..381d58a8a9c 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -43,6 +43,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -81,24 +82,26 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" -FAN_YAML_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_DIRECTION): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_OSCILLATING): cv.template, - vol.Optional(CONF_PERCENTAGE): cv.template, - vol.Optional(CONF_PRESET_MODE): cv.template, - vol.Optional(CONF_PRESET_MODES): cv.ensure_list, - vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), - vol.Optional(CONF_STATE): cv.template, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +FAN_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DIRECTION): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_OSCILLATING): cv.template, + vol.Optional(CONF_PERCENTAGE): cv.template, + vol.Optional(CONF_PRESET_MODE): cv.template, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), + vol.Optional(CONF_STATE): cv.template, + } +) + +FAN_YAML_SCHEMA = FAN_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA).extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema ) FAN_LEGACY_YAML_SCHEMA = vol.All( @@ -154,13 +157,12 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - - self._template = config.get(CONF_STATE) self._percentage_template = config.get(CONF_PERCENTAGE) self._preset_mode_template = config.get(CONF_PRESET_MODE) self._oscillating_template = config.get(CONF_OSCILLATING) @@ -177,7 +179,6 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): # List of valid preset modes self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) - self._attr_assumed_state = self._template is None self._attr_supported_features |= ( FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON @@ -339,7 +340,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): if percentage is not None: await self.async_set_percentage(percentage) - if self._template is None: + if self._attr_assumed_state: self._state = True self.async_write_ha_state() @@ -349,7 +350,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): self._action_scripts[CONF_OFF_ACTION], context=self._context ) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -364,10 +365,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = percentage != 0 - if self._template is None or self._percentage_template is None: + if self._attr_assumed_state or self._percentage_template is None: self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -381,10 +382,10 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): context=self._context, ) - if self._template is None: + if self._attr_assumed_state: self._state = True - if self._template is None or self._preset_mode_template is None: + if self._attr_assumed_state or self._preset_mode_template is None: self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 708ad6bdecd..c0af18166df 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -1833,3 +1833,51 @@ async def test_nested_unique_id( entry = entity_registry.async_get("fan.test_b") assert entry assert entry.unique_id == "x-b" + + +@pytest.mark.parametrize( + ("count", "fan_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('sensor.test_sensor', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_fan") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(_STATE_TEST_SENSOR, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From d823b574c0f57040c1b3bc9f5eae473d279ea811 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:59:57 -0400 Subject: [PATCH 0703/1118] Add optimistic option to light yaml (#149395) --- homeassistant/components/template/light.py | 19 +++++--- tests/components/template/test_light.py | 51 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 07591ce9653..19eecaa7006 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -53,6 +53,7 @@ from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -121,7 +122,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" -LIGHT_YAML_SCHEMA = vol.Schema( +LIGHT_COMMON_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -132,6 +133,8 @@ LIGHT_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_LEVEL): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.template, vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, @@ -142,9 +145,11 @@ LIGHT_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, } +) + +LIGHT_YAML_SCHEMA = LIGHT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LIGHT_LEGACY_YAML_SCHEMA = vol.All( @@ -215,6 +220,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. @@ -224,7 +230,6 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Initialize the features.""" # Template attributes - self._template = config.get(CONF_STATE) self._level_template = config.get(CONF_LEVEL) self._temperature_template = config.get(CONF_TEMPERATURE) self._hs_template = config.get(CONF_HS) @@ -349,7 +354,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): Returns True if any attribute was updated. """ optimistic_set = False - if self._template is None: + if self._attr_assumed_state: self._state = True optimistic_set = True @@ -1066,7 +1071,7 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() @@ -1205,6 +1210,6 @@ class TriggerLightEntity(TriggerEntity, AbstractTemplateLight): ) else: await self.async_run_script(off_script, context=self._context) - if self._template is None: + if self._attr_assumed_state: self._state = False self.async_write_ha_state() diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index bfffd0911a9..b42eba0665d 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -37,6 +37,9 @@ from tests.common import assert_setup_component # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" +TEST_OBJECT_ID = "test_light" +TEST_ENTITY_ID = f"light.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "light.test_state" OPTIMISTIC_ON_OFF_LIGHT_CONFIG = { "turn_on": { @@ -2740,3 +2743,51 @@ async def test_effect_with_empty_action( """Test empty set_effect action.""" state = hass.states.get("light.test_template_light") assert state.attributes["supported_features"] == LightEntityFeature.EFFECT + + +@pytest.mark.parametrize( + ("count", "light_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ is_state('light.test_state', 'on') }}", + "turn_on": [], + "turn_off": [], + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_light") +async def test_optimistic_option(hass: HomeAssistant) -> None: + """Test optimistic yaml option.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + await hass.services.async_call( + light.DOMAIN, + "turn_on", + {"entity_id": TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF From 8f795f021c0f397d66cf38298cc8e46bcc3c2bce Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 28 Jul 2025 17:19:43 +0200 Subject: [PATCH 0704/1118] Bump Plugwise to v1.7.8 preventing rogue KeyError (#149000) --- homeassistant/components/plugwise/climate.py | 2 +- homeassistant/components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/select.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/fixtures/m_adam_jip/data.json | 8 ++++++++ tests/components/plugwise/test_climate.py | 7 +++++-- 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 71846a04bbd..22f204444d5 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -165,7 +165,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "regulation_modes" in self._gateway_data: hvac_modes.append(HVACMode.OFF) - if "available_schedules" in self.device: + if self.device.get("available_schedules"): hvac_modes.append(HVACMode.AUTO) if self.coordinator.api.cooling_present: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 09cec98292a..69b456ca8d8 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.7"], + "requirements": ["plugwise==1.7.8"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 6ca1d4ce7a2..6fc8f1615a7 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -70,7 +70,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data[device_id] + if coordinator.data[device_id].get(description.options_key) ) _add_entities() @@ -98,7 +98,7 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): self._location = location @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] diff --git a/requirements_all.txt b/requirements_all.txt index b2e14e4241c..41947853270 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.7 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21eab297f03..c807c93a6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1431,7 +1431,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.7 +plugwise==1.7.8 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json index 8de57910f66..50b9a8109ee 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -1,11 +1,13 @@ { "06aecb3d00354375924f50c47af36bd2": { "active_preset": "no_frost", + "available_schedules": [], "climate_mode": "off", "dev_class": "climate", "model": "ThermoZone", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 24.2 }, @@ -23,12 +25,14 @@ }, "13228dab8ce04617af318a2888b3c548": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Woonkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 27.4 }, @@ -236,12 +240,14 @@ }, "d27aede973b54be484f6842d1b2802ad": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Kinderkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, @@ -283,12 +289,14 @@ }, "d58fec52899f4f1c92e4f8fad6d8c48c": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Logeerkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 30.0 }, diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 3787cbf7150..b8554f9a5cc 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -433,13 +433,16 @@ async def test_anna_climate_entity_climate_changes( "c784ee9fdab44e1395b8dee7d7a497d5", HVACMode.OFF ) + # Mock user deleting last schedule from app or browser data = mock_smile_anna.async_update.return_value - data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = [] + data["3cb70739631c4d17a86b8b12e8a5161b"]["select_schedule"] = None + data["3cb70739631c4d17a86b8b12e8a5161b"]["climate_mode"] = "heat_cool" with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("climate.anna") - assert state.state == HVACMode.HEAT + assert state.state == HVACMode.HEAT_COOL assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL] From 483d814a8f227e27b935f70f0e8ee587213df183 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:24:15 +0200 Subject: [PATCH 0705/1118] Add new Volvo integration (#142994) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/volvo/__init__.py | 97 + homeassistant/components/volvo/api.py | 38 + .../volvo/application_credentials.py | 37 + homeassistant/components/volvo/config_flow.py | 239 + homeassistant/components/volvo/const.py | 14 + homeassistant/components/volvo/coordinator.py | 255 ++ homeassistant/components/volvo/entity.py | 90 + homeassistant/components/volvo/icons.json | 81 + homeassistant/components/volvo/manifest.json | 13 + .../components/volvo/quality_scale.yaml | 82 + homeassistant/components/volvo/sensor.py | 388 ++ homeassistant/components/volvo/strings.json | 178 + .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/volvo/__init__.py | 52 + tests/components/volvo/conftest.py | 185 + tests/components/volvo/const.py | 19 + .../volvo/fixtures/availability.json | 6 + tests/components/volvo/fixtures/brakes.json | 6 + tests/components/volvo/fixtures/commands.json | 36 + .../volvo/fixtures/diagnostics.json | 25 + tests/components/volvo/fixtures/doors.json | 34 + .../volvo/fixtures/energy_capabilities.json | 33 + .../volvo/fixtures/energy_state.json | 42 + .../volvo/fixtures/engine_status.json | 6 + .../volvo/fixtures/engine_warnings.json | 10 + .../ex30_2024/energy_capabilities.json | 33 + .../fixtures/ex30_2024/energy_state.json | 57 + .../volvo/fixtures/ex30_2024/statistics.json | 32 + .../volvo/fixtures/ex30_2024/vehicle.json | 17 + .../volvo/fixtures/fuel_status.json | 12 + tests/components/volvo/fixtures/location.json | 11 + tests/components/volvo/fixtures/odometer.json | 7 + .../volvo/fixtures/recharge_status.json | 25 + .../fixtures/s90_diesel_2018/diagnostics.json | 25 + .../fixtures/s90_diesel_2018/statistics.json | 32 + .../fixtures/s90_diesel_2018/vehicle.json | 16 + tests/components/volvo/fixtures/tyres.json | 18 + tests/components/volvo/fixtures/warnings.json | 94 + tests/components/volvo/fixtures/windows.json | 22 + .../energy_capabilities.json | 33 + .../xc40_electric_2024/energy_state.json | 58 + .../xc40_electric_2024/statistics.json | 32 + .../fixtures/xc40_electric_2024/vehicle.json | 17 + .../fixtures/xc90_petrol_2019/commands.json | 44 + .../fixtures/xc90_petrol_2019/statistics.json | 32 + .../fixtures/xc90_petrol_2019/vehicle.json | 16 + .../volvo/snapshots/test_sensor.ambr | 3833 +++++++++++++++++ tests/components/volvo/test_config_flow.py | 303 ++ tests/components/volvo/test_coordinator.py | 151 + tests/components/volvo/test_init.py | 125 + tests/components/volvo/test_sensor.py | 32 + 58 files changed, 7070 insertions(+) create mode 100644 homeassistant/components/volvo/__init__.py create mode 100644 homeassistant/components/volvo/api.py create mode 100644 homeassistant/components/volvo/application_credentials.py create mode 100644 homeassistant/components/volvo/config_flow.py create mode 100644 homeassistant/components/volvo/const.py create mode 100644 homeassistant/components/volvo/coordinator.py create mode 100644 homeassistant/components/volvo/entity.py create mode 100644 homeassistant/components/volvo/icons.json create mode 100644 homeassistant/components/volvo/manifest.json create mode 100644 homeassistant/components/volvo/quality_scale.yaml create mode 100644 homeassistant/components/volvo/sensor.py create mode 100644 homeassistant/components/volvo/strings.json create mode 100644 tests/components/volvo/__init__.py create mode 100644 tests/components/volvo/conftest.py create mode 100644 tests/components/volvo/const.py create mode 100644 tests/components/volvo/fixtures/availability.json create mode 100644 tests/components/volvo/fixtures/brakes.json create mode 100644 tests/components/volvo/fixtures/commands.json create mode 100644 tests/components/volvo/fixtures/diagnostics.json create mode 100644 tests/components/volvo/fixtures/doors.json create mode 100644 tests/components/volvo/fixtures/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/energy_state.json create mode 100644 tests/components/volvo/fixtures/engine_status.json create mode 100644 tests/components/volvo/fixtures/engine_warnings.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/energy_state.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/statistics.json create mode 100644 tests/components/volvo/fixtures/ex30_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/fuel_status.json create mode 100644 tests/components/volvo/fixtures/location.json create mode 100644 tests/components/volvo/fixtures/odometer.json create mode 100644 tests/components/volvo/fixtures/recharge_status.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/statistics.json create mode 100644 tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json create mode 100644 tests/components/volvo/fixtures/tyres.json create mode 100644 tests/components/volvo/fixtures/warnings.json create mode 100644 tests/components/volvo/fixtures/windows.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/statistics.json create mode 100644 tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/commands.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json create mode 100644 tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json create mode 100644 tests/components/volvo/snapshots/test_sensor.ambr create mode 100644 tests/components/volvo/test_config_flow.py create mode 100644 tests/components/volvo/test_coordinator.py create mode 100644 tests/components/volvo/test_init.py create mode 100644 tests/components/volvo/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 3f87bfa18e8..c6e27a011f1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -547,6 +547,7 @@ homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* +homeassistant.components.volvo.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/CODEOWNERS b/CODEOWNERS index f4f1d3b7a92..4e7c1b9175a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1706,6 +1706,8 @@ build.json @home-assistant/supervisor /tests/components/voip/ @balloob @synesthesiam @jaminh /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund +/homeassistant/components/volvo/ @thomasddn +/tests/components/volvo/ @thomasddn /homeassistant/components/volvooncall/ @molobrakos /tests/components/volvooncall/ @molobrakos /homeassistant/components/vulcan/ @Antoni-Czaplicki diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py new file mode 100644 index 00000000000..c6632185f0a --- /dev/null +++ b/homeassistant/components/volvo/__init__.py @@ -0,0 +1,97 @@ +"""The Volvo integration.""" + +from __future__ import annotations + +import asyncio + +from aiohttp import ClientResponseError +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import VolvoAuth +from .const import CONF_VIN, DOMAIN, PLATFORMS +from .coordinator import ( + VolvoConfigEntry, + VolvoMediumIntervalCoordinator, + VolvoSlowIntervalCoordinator, + VolvoVerySlowIntervalCoordinator, +) + + +async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Set up Volvo from a config entry.""" + + api = await _async_auth_and_create_api(hass, entry) + vehicle = await _async_load_vehicle(api) + + # Order is important! Faster intervals must come first. + coordinators = ( + VolvoMediumIntervalCoordinator(hass, entry, api, vehicle), + VolvoSlowIntervalCoordinator(hass, entry, api, vehicle), + VolvoVerySlowIntervalCoordinator(hass, entry, api, vehicle), + ) + + await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_auth_and_create_api( + hass: HomeAssistant, entry: VolvoConfigEntry +) -> VolvoCarsApi: + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) + web_session = async_get_clientsession(hass) + auth = VolvoAuth(web_session, oauth_session) + + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in (400, 401): + raise ConfigEntryAuthFailed from err + + raise ConfigEntryNotReady from err + + return VolvoCarsApi( + web_session, + auth, + entry.data[CONF_API_KEY], + entry.data[CONF_VIN], + ) + + +async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: + try: + vehicle = await api.async_get_vehicle_details() + except VolvoAuthException as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="unauthorized", + translation_placeholders={"message": ex.message}, + ) from ex + + if vehicle is None: + raise ConfigEntryError(translation_domain=DOMAIN, translation_key="no_vehicle") + + return vehicle diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py new file mode 100644 index 00000000000..e2c1070f1ea --- /dev/null +++ b/homeassistant/components/volvo/api.py @@ -0,0 +1,38 @@ +"""API for Volvo bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from volvocarsapi.auth import AccessTokenManager + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +class VolvoAuth(AccessTokenManager): + """Provide Volvo authentication tied to an OAuth2 based config entry.""" + + def __init__(self, websession: ClientSession, oauth_session: OAuth2Session) -> None: + """Initialize Volvo auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token["access_token"]) + + +class ConfigFlowVolvoAuth(AccessTokenManager): + """Provide Volvo authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__(self, websession: ClientSession, token: str) -> None: + """Initialize ConfigFlowVolvoAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Volvo API.""" + return self._token diff --git a/homeassistant/components/volvo/application_credentials.py b/homeassistant/components/volvo/application_credentials.py new file mode 100644 index 00000000000..18dae40f8ee --- /dev/null +++ b/homeassistant/components/volvo/application_credentials.py @@ -0,0 +1,37 @@ +"""Application credentials platform for the Volvo integration.""" + +from __future__ import annotations + +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.scopes import DEFAULT_SCOPES + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> VolvoOAuth2Implementation: + """Return auth implementation for a custom auth implementation.""" + return VolvoOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + AUTHORIZE_URL, + TOKEN_URL, + credential.client_secret, + ) + + +class VolvoOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Volvo oauth2 implementation.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(DEFAULT_SCOPES), + } diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py new file mode 100644 index 00000000000..05d19fd1d26 --- /dev/null +++ b/homeassistant/components/volvo/config_flow.py @@ -0,0 +1,239 @@ +"""Config flow for Volvo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle + +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlowResult, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .api import ConfigFlowVolvoAuth +from .const import CONF_VIN, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +def _create_volvo_cars_api( + hass: HomeAssistant, access_token: str, api_key: str +) -> VolvoCarsApi: + web_session = aiohttp_client.async_get_clientsession(hass) + auth = ConfigFlowVolvoAuth(web_session, access_token) + return VolvoCarsApi(web_session, auth, api_key) + + +class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Volvo OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Initialize Volvo config flow.""" + super().__init__() + + self._vehicles: list[VolvoCarsVehicle] = [] + self._config_data: dict = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an entry for the flow.""" + self._config_data |= data + return await self.async_step_api_key() + + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_api_key() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) + return await self.async_step_user() + + async def async_step_api_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the API key step.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + user_input[CONF_API_KEY], + ) + + # Try to load all vehicles on the account. If it succeeds + # it means that the given API key is correct. The vehicle info + # is used in the VIN step. + try: + await self._async_load_vehicles(api) + except VolvoApiException: + _LOGGER.exception("Unable to retrieve vehicles") + errors["base"] = "cannot_load_vehicles" + + if not errors: + self._config_data |= user_input + return await self.async_step_vin() + + if user_input is None: + if self.source == SOURCE_REAUTH: + user_input = self._config_data = dict(self._get_reauth_entry().data) + api = _create_volvo_cars_api( + self.hass, + self._config_data[CONF_TOKEN][CONF_ACCESS_TOKEN], + self._config_data[CONF_API_KEY], + ) + + # Test if the configured API key is still valid. If not, show this + # form. If it is, skip this step and go directly to the next step. + try: + await self._async_load_vehicles(api) + return await self.async_step_vin() + except VolvoApiException: + pass + + elif self.source == SOURCE_RECONFIGURE: + user_input = self._config_data = dict( + self._get_reconfigure_entry().data + ) + else: + user_input = {} + + schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, autocomplete="password" + ) + ), + } + ), + { + CONF_API_KEY: user_input.get(CONF_API_KEY, ""), + }, + ) + + return self.async_show_form( + step_id="api_key", + data_schema=schema, + errors=errors, + description_placeholders={ + "volvo_dev_portal": "https://developer.volvocars.com/account/#your-api-applications" + }, + ) + + async def async_step_vin( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the VIN step.""" + errors: dict[str, str] = {} + + if len(self._vehicles) == 1: + # If there is only one VIN, take that as value and + # immediately create the entry. No need to show + # the VIN step. + self._config_data[CONF_VIN] = self._vehicles[0].vin + return await self._async_create_or_update() + + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): + # Don't let users change the VIN. The entry should be + # recreated if they want to change the VIN. + return await self._async_create_or_update() + + if user_input is not None: + self._config_data |= user_input + return await self._async_create_or_update() + + if len(self._vehicles) == 0: + errors[CONF_VIN] = "no_vehicles" + + schema = vol.Schema( + { + vol.Required(CONF_VIN): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=v.vin, + label=f"{v.description.model} ({v.vin})", + ) + for v in self._vehicles + ], + multiple=False, + ) + ), + }, + ) + + return self.async_show_form(step_id="vin", data_schema=schema, errors=errors) + + async def _async_create_or_update(self) -> ConfigFlowResult: + vin = self._config_data[CONF_VIN] + await self.async_set_unique_id(vin) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=self._config_data, + ) + + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config_data, + reload_even_if_entry_is_unchanged=False, + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {vin}", + data=self._config_data, + ) + + async def _async_load_vehicles(self, api: VolvoCarsApi) -> None: + self._vehicles = [] + vins = await api.async_get_vehicles() + + for vin in vins: + vehicle = await api.async_get_vehicle_details(vin) + + if vehicle: + self._vehicles.append(vehicle) diff --git a/homeassistant/components/volvo/const.py b/homeassistant/components/volvo/const.py new file mode 100644 index 00000000000..675fc69945e --- /dev/null +++ b/homeassistant/components/volvo/const.py @@ -0,0 +1,14 @@ +"""Constants for the Volvo integration.""" + +from homeassistant.const import Platform + +DOMAIN = "volvo" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +ATTR_API_TIMESTAMP = "api_timestamp" + +CONF_VIN = "vin" + +DATA_BATTERY_CAPACITY = "battery_capacity_kwh" + +MANUFACTURER = "Volvo" diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py new file mode 100644 index 00000000000..8ddaaee0781 --- /dev/null +++ b/homeassistant/components/volvo/coordinator.py @@ -0,0 +1,255 @@ +"""Volvo coordinators.""" + +from __future__ import annotations + +from abc import abstractmethod +import asyncio +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsVehicle, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DATA_BATTERY_CAPACITY, DOMAIN + +VERY_SLOW_INTERVAL = 60 +SLOW_INTERVAL = 15 +MEDIUM_INTERVAL = 2 + +_LOGGER = logging.getLogger(__name__) + + +type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]] +type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] + + +class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): + """Volvo base coordinator.""" + + config_entry: VolvoConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + update_interval: timedelta, + name: str, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=name, + update_interval=update_interval, + ) + + self.api = api + self.vehicle = vehicle + + self._api_calls: list[Callable[[], Coroutine[Any, Any, Any]]] = [] + + async def _async_setup(self) -> None: + self._api_calls = await self._async_determine_api_calls() + + if not self._api_calls: + self.update_interval = None + + async def _async_update_data(self) -> CoordinatorData: + """Fetch data from API.""" + + data: CoordinatorData = {} + + if not self._api_calls: + return data + + valid = False + exception: Exception | None = None + + results = await asyncio.gather( + *(call() for call in self._api_calls), return_exceptions=True + ) + + for result in results: + if isinstance(result, VolvoAuthException): + # If one result is a VolvoAuthException, then probably all requests + # will fail. In this case we can cancel everything to + # reauthenticate. + # + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.debug( + "%s - Authentication failed. %s", + self.config_entry.entry_id, + result.message, + ) + raise ConfigEntryAuthFailed( + f"Authentication failed. {result.message}" + ) from result + + if isinstance(result, VolvoApiException): + # Maybe it's just one call that fails. Log the error and + # continue processing the other calls. + _LOGGER.debug( + "%s - Error during data update: %s", + self.config_entry.entry_id, + result.message, + ) + exception = exception or result + continue + + if isinstance(result, Exception): + # Something bad happened, raise immediately. + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from result + + data |= cast(CoordinatorData, result) + valid = True + + # Raise an error if not a single API call succeeded + if not valid: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from exception + + return data + + def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None: + """Get the API field based on the entity description.""" + + return self.data.get(api_field) if api_field else None + + @abstractmethod + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + raise NotImplementedError + + +class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with very slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=VERY_SLOW_INTERVAL), + "Volvo very slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + return [ + self.api.async_get_diagnostics, + self.api.async_get_odometer, + self.api.async_get_statistics, + ] + + async def _async_update_data(self) -> CoordinatorData: + data = await super()._async_update_data() + + # Add static values + if self.vehicle.has_battery_engine(): + data[DATA_BATTERY_CAPACITY] = VolvoCarsValue.from_dict( + { + "value": self.vehicle.battery_capacity_kwh, + } + ) + + return data + + +class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with slow update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=SLOW_INTERVAL), + "Volvo slow interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_combustion_engine(): + return [ + self.api.async_get_command_accessibility, + self.api.async_get_fuel_status, + ] + + return [self.api.async_get_command_accessibility] + + +class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator): + """Volvo coordinator with medium update rate.""" + + def __init__( + self, + hass: HomeAssistant, + entry: VolvoConfigEntry, + api: VolvoCarsApi, + vehicle: VolvoCarsVehicle, + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + entry, + api, + vehicle, + timedelta(minutes=MEDIUM_INTERVAL), + "Volvo medium interval coordinator", + ) + + async def _async_determine_api_calls( + self, + ) -> list[Callable[[], Coroutine[Any, Any, Any]]]: + if self.vehicle.has_battery_engine(): + capabilities = await self.api.async_get_energy_capabilities() + + if capabilities.get("isSupported", False): + return [self.api.async_get_energy_state] + + return [] diff --git a/homeassistant/components/volvo/entity.py b/homeassistant/components/volvo/entity.py new file mode 100644 index 00000000000..f23bd714870 --- /dev/null +++ b/homeassistant/components/volvo/entity.py @@ -0,0 +1,90 @@ +"""Volvo entity classes.""" + +from abc import abstractmethod +from dataclasses import dataclass + +from volvocarsapi.models import VolvoCarsApiBaseModel + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_VIN, DOMAIN, MANUFACTURER +from .coordinator import VolvoBaseCoordinator + + +def get_unique_id(vin: str, key: str) -> str: + """Get the unique ID.""" + return f"{vin}_{key}".lower() + + +def value_to_translation_key(value: str) -> str: + """Make sure the translation key is valid.""" + return value.lower() + + +@dataclass(frozen=True, kw_only=True) +class VolvoEntityDescription(EntityDescription): + """Describes a Volvo entity.""" + + api_field: str + + +class VolvoEntity(CoordinatorEntity[VolvoBaseCoordinator]): + """Volvo base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: VolvoBaseCoordinator, + description: VolvoEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator) + + self.entity_description: VolvoEntityDescription = description + + if description.device_class != SensorDeviceClass.BATTERY: + self._attr_translation_key = description.key + + self._attr_unique_id = get_unique_id( + coordinator.config_entry.data[CONF_VIN], description.key + ) + + vehicle = coordinator.vehicle + model = ( + f"{vehicle.description.model} ({vehicle.model_year})" + if vehicle.fuel_type == "NONE" + else f"{vehicle.description.model} {vehicle.fuel_type} ({vehicle.model_year})" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, vehicle.vin)}, + manufacturer=MANUFACTURER, + model=model, + name=f"{MANUFACTURER} {vehicle.description.model}", + serial_number=vehicle.vin, + ) + + self._update_state(coordinator.get_api_field(description.api_field)) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + self._update_state(api_field) + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + api_field = self.coordinator.get_api_field(self.entity_description.api_field) + return super().available and api_field is not None + + @abstractmethod + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + raise NotImplementedError diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json new file mode 100644 index 00000000000..8e2897c66ad --- /dev/null +++ b/homeassistant/components/volvo/icons.json @@ -0,0 +1,81 @@ +{ + "entity": { + "sensor": { + "availability": { + "default": "mdi:car-connected" + }, + "average_energy_consumption": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_automatic": { + "default": "mdi:car-electric" + }, + "average_energy_consumption_charge": { + "default": "mdi:car-electric" + }, + "average_fuel_consumption": { + "default": "mdi:gas-station" + }, + "average_fuel_consumption_automatic": { + "default": "mdi:gas-station" + }, + "charger_connection_status": { + "default": "mdi:ev-plug-ccs2" + }, + "charging_power": { + "default": "mdi:gauge-empty", + "range": { + "1": "mdi:gauge-low", + "4200": "mdi:gauge", + "7400": "mdi:gauge-full" + } + }, + "charging_power_status": { + "default": "mdi:power-plug-outline" + }, + "charging_status": { + "default": "mdi:ev-station" + }, + "charging_type": { + "default": "mdi:power-plug-off-outline", + "state": { + "ac": "mdi:current-ac", + "dc": "mdi:current-dc" + } + }, + "distance_to_empty_battery": { + "default": "mdi:gauge-empty" + }, + "distance_to_empty_tank": { + "default": "mdi:gauge-empty" + }, + "distance_to_service": { + "default": "mdi:wrench-clock" + }, + "engine_time_to_service": { + "default": "mdi:wrench-clock" + }, + "estimated_charging_time": { + "default": "mdi:battery-clock" + }, + "fuel_amount": { + "default": "mdi:gas-station" + }, + "odometer": { + "default": "mdi:counter" + }, + "target_battery_charge_level": { + "default": "mdi:battery-medium" + }, + "time_to_service": { + "default": "mdi:wrench-clock" + }, + "trip_meter_automatic": { + "default": "mdi:map-marker-distance" + }, + "trip_meter_manual": { + "default": "mdi:map-marker-distance" + } + } + } +} diff --git a/homeassistant/components/volvo/manifest.json b/homeassistant/components/volvo/manifest.json new file mode 100644 index 00000000000..1530634a10a --- /dev/null +++ b/homeassistant/components/volvo/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "volvo", + "name": "Volvo", + "codeowners": ["@thomasddn"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/volvo", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["volvocarsapi"], + "quality_scale": "silver", + "requirements": ["volvocarsapi==0.4.1"] +} diff --git a/homeassistant/components/volvo/quality_scale.yaml b/homeassistant/components/volvo/quality_scale.yaml new file mode 100644 index 00000000000..ac91fd001d1 --- /dev/null +++ b/homeassistant/components/volvo/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-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 provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery possible. + discovery: + status: exempt + comment: | + No discovery possible. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Devices are handpicked because there is a rate limit on the API, which we + would hit if all devices (vehicles) are added under the same API key. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: todo + stale-devices: + status: exempt + comment: | + Devices are handpicked. See dynamic-devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py new file mode 100644 index 00000000000..b8949f5e73d --- /dev/null +++ b/homeassistant/components/volvo/sensor.py @@ -0,0 +1,388 @@ +"""Volvo sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, replace +import logging +from typing import Any, cast + +from volvocarsapi.models import ( + VolvoCarsApiBaseModel, + VolvoCarsValue, + VolvoCarsValueField, + VolvoCarsValueStatusField, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfEnergyDistance, + UnitOfLength, + UnitOfPower, + UnitOfSpeed, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DATA_BATTERY_CAPACITY +from .coordinator import VolvoBaseCoordinator, VolvoConfigEntry +from .entity import VolvoEntity, VolvoEntityDescription, value_to_translation_key + +PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): + """Describes a Volvo sensor entity.""" + + source_fields: list[str] | None = None + value_fn: Callable[[VolvoCarsValue], Any] | None = None + + +def _availability_status(field: VolvoCarsValue) -> str: + reason = field.get("unavailable_reason") + return reason if reason else str(field.value) + + +def _calculate_time_to_service(field: VolvoCarsValue) -> int: + value = int(field.value) + + # Always express value in days + if isinstance(field, VolvoCarsValueField) and field.unit == "months": + return value * 30 + + return value + + +def _charging_power_value(field: VolvoCarsValue) -> int: + return ( + int(field.value) + if isinstance(field, VolvoCarsValueStatusField) and field.status == "OK" + else 0 + ) + + +def _charging_power_status_value(field: VolvoCarsValue) -> str | None: + status = cast(str, field.value) + + if status.lower() in _CHARGING_POWER_STATUS_OPTIONS: + return status + + _LOGGER.warning( + "Unknown value '%s' for charging_power_status. Please report it at https://github.com/home-assistant/core/issues/new?template=bug_report.yml", + status, + ) + return None + + +_CHARGING_POWER_STATUS_OPTIONS = ["providing_power", "no_power_available"] + +_DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( + # command-accessibility endpoint + VolvoSensorDescription( + key="availability", + api_field="availabilityStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "car_in_use", + "no_internet", + "ota_installation_in_progress", + "power_saving_mode", + ], + value_fn=_availability_status, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption", + api_field="averageEnergyConsumption", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_automatic", + api_field="averageEnergyConsumptionAutomatic", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_energy_consumption_charge", + api_field="averageEnergyConsumptionSinceCharge", + native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption", + api_field="averageFuelConsumption", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_fuel_consumption_automatic", + api_field="averageFuelConsumptionAutomatic", + native_unit_of_measurement="L/100 km", + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed", + api_field="averageSpeed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="average_speed_automatic", + api_field="averageSpeedAutomatic", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), + # vehicle endpoint + VolvoSensorDescription( + key="battery_capacity", + api_field=DATA_BATTERY_CAPACITY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # fuel & energy state endpoint + VolvoSensorDescription( + key="battery_charge_level", + api_field="batteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + # energy state endpoint + VolvoSensorDescription( + key="charger_connection_status", + api_field="chargerConnectionStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "connected", + "disconnected", + "fault", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_current_limit", + api_field="chargingCurrentLimit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power", + api_field="chargingPower", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=_charging_power_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_power_status", + api_field="chargerPowerStatus", + device_class=SensorDeviceClass.ENUM, + options=_CHARGING_POWER_STATUS_OPTIONS, + value_fn=_charging_power_status_value, + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_status", + api_field="chargingStatus", + device_class=SensorDeviceClass.ENUM, + options=[ + "charging", + "discharging", + "done", + "error", + "idle", + "scheduled", + ], + ), + # energy state endpoint + VolvoSensorDescription( + key="charging_type", + api_field="chargingType", + device_class=SensorDeviceClass.ENUM, + options=[ + "ac", + "dc", + "none", + ], + ), + # statistics & energy state endpoint + VolvoSensorDescription( + key="distance_to_empty_battery", + api_field="", + source_fields=["distanceToEmptyBattery", "electricRange"], + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # statistics endpoint + VolvoSensorDescription( + key="distance_to_empty_tank", + api_field="distanceToEmptyTank", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="distance_to_service", + api_field="distanceToService", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="engine_time_to_service", + api_field="engineHoursToService", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # energy state endpoint + VolvoSensorDescription( + key="estimated_charging_time", + api_field="estimatedChargingTimeToTargetBatteryChargeLevel", + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + # fuel endpoint + VolvoSensorDescription( + key="fuel_amount", + api_field="fuelAmount", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + # odometer endpoint + VolvoSensorDescription( + key="odometer", + api_field="odometer", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # energy state endpoint + VolvoSensorDescription( + key="target_battery_charge_level", + api_field="targetBatteryChargeLevel", + native_unit_of_measurement=PERCENTAGE, + ), + # diagnostics endpoint + VolvoSensorDescription( + key="time_to_service", + api_field="timeToService", + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=_calculate_time_to_service, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_automatic", + api_field="tripMeterAutomatic", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + # statistics endpoint + VolvoSensorDescription( + key="trip_meter_manual", + api_field="tripMeterManual", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VolvoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors.""" + + entities: list[VolvoSensor] = [] + added_keys: set[str] = set() + + def _add_entity( + coordinator: VolvoBaseCoordinator, description: VolvoSensorDescription + ) -> None: + entities.append(VolvoSensor(coordinator, description)) + added_keys.add(description.key) + + coordinators = entry.runtime_data + + for coordinator in coordinators: + for description in _DESCRIPTIONS: + if description.key in added_keys: + continue + + if description.source_fields: + for field in description.source_fields: + if field in coordinator.data: + description = replace(description, api_field=field) + _add_entity(coordinator, description) + elif description.api_field in coordinator.data: + _add_entity(coordinator, description) + + async_add_entities(entities) + + +class VolvoSensor(VolvoEntity, SensorEntity): + """Volvo sensor.""" + + entity_description: VolvoSensorDescription + + def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None: + """Update the state of the entity.""" + if api_field is None: + self._attr_native_value = None + return + + assert isinstance(api_field, VolvoCarsValue) + + native_value = ( + api_field.value + if self.entity_description.value_fn is None + else self.entity_description.value_fn(api_field) + ) + + if self.device_class == SensorDeviceClass.ENUM and native_value: + # Entities having an "unknown" value should report None as the state + native_value = str(native_value) + native_value = ( + value_to_translation_key(native_value) + if native_value.upper() != "UNSPECIFIED" + else None + ) + + self._attr_native_value = native_value diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json new file mode 100644 index 00000000000..4fe7429117c --- /dev/null +++ b/homeassistant/components/volvo/strings.json @@ -0,0 +1,178 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Volvo integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "api_key": { + "description": "Get your API key from the [Volvo developer portal]({volvo_dev_portal}).", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The Volvo developers API key" + } + }, + "vin": { + "description": "Select a vehicle", + "data": { + "vin": "VIN" + }, + "data_description": { + "vin": "The Vehicle Identification Number of the vehicle you want to add" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "error": { + "cannot_load_vehicles": "Unable to retrieve vehicles.", + "no_vehicles": "No vehicles found on this account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "availability": { + "name": "Car connection", + "state": { + "available": "Available", + "car_in_use": "Car is in use", + "no_internet": "No internet", + "ota_installation_in_progress": "Installing OTA update", + "power_saving_mode": "Power saving mode", + "unavailable": "Unavailable" + } + }, + "average_energy_consumption": { + "name": "Trip manual average energy consumption" + }, + "average_energy_consumption_automatic": { + "name": "Trip automatic average energy consumption" + }, + "average_energy_consumption_charge": { + "name": "Average energy consumption since charge" + }, + "average_fuel_consumption": { + "name": "Trip manual average fuel consumption" + }, + "average_fuel_consumption_automatic": { + "name": "Trip automatic average fuel consumption" + }, + "average_speed": { + "name": "Trip manual average speed" + }, + "average_speed_automatic": { + "name": "Trip automatic average speed" + }, + "battery_capacity": { + "name": "Battery capacity" + }, + "battery_charge_level": { + "name": "Battery charge level" + }, + "charger_connection_status": { + "name": "Charging connection status", + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "fault": "[%key:common::state::error%]" + } + }, + "charging_current_limit": { + "name": "Charging limit" + }, + "charging_power": { + "name": "Charging power" + }, + "charging_power_status": { + "name": "Charging power status", + "state": { + "providing_power": "Providing power", + "no_power_available": "No power" + } + }, + "charging_status": { + "name": "Charging status", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "done": "Done", + "error": "[%key:common::state::error%]", + "idle": "[%key:common::state::idle%]", + "scheduled": "Scheduled" + } + }, + "charging_type": { + "name": "Charging type", + "state": { + "ac": "AC", + "dc": "DC", + "none": "None" + } + }, + "distance_to_empty_battery": { + "name": "Distance to empty battery" + }, + "distance_to_empty_tank": { + "name": "Distance to empty tank" + }, + "distance_to_service": { + "name": "Distance to service" + }, + "engine_time_to_service": { + "name": "Time to engine service" + }, + "estimated_charging_time": { + "name": "Estimated charging time" + }, + "fuel_amount": { + "name": "Fuel amount" + }, + "odometer": { + "name": "Odometer" + }, + "target_battery_charge_level": { + "name": "Target battery charge level" + }, + "time_to_service": { + "name": "Time to service" + }, + "trip_meter_automatic": { + "name": "Trip automatic distance" + }, + "trip_meter_manual": { + "name": "Trip manual distance" + } + } + }, + "exceptions": { + "no_vehicle": { + "message": "Unable to retrieve vehicle details." + }, + "update_failed": { + "message": "Unable to update data." + }, + "unauthorized": { + "message": "Authentication failed. {message}" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 2f088716f8c..0abd4365feb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -35,6 +35,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "twitch", + "volvo", "weheat", "withings", "xbox", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d9fd32d204b..5d468fd1dc9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -701,6 +701,7 @@ FLOWS = { "vodafone_station", "voip", "volumio", + "volvo", "volvooncall", "vulcan", "wake_on_lan", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 24f72add2ec..a673b05218d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7267,6 +7267,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "volvo": { + "name": "Volvo", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "volvooncall": { "name": "Volvo On Call", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index bfd9cfb0a84..ba5ac08d3c9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5229,6 +5229,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.volvo.*] +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.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 41947853270..943321cbf31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3059,6 +3059,9 @@ voip-utils==0.3.3 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.volvo +volvocarsapi==0.4.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c807c93a6c5..de76a345a62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2524,6 +2524,9 @@ vilfo-api-client==0.5.0 # homeassistant.components.voip voip-utils==0.3.3 +# homeassistant.components.volvo +volvocarsapi==0.4.1 + # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py new file mode 100644 index 00000000000..875052fcf7e --- /dev/null +++ b/tests/components/volvo/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Volvo integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from volvocarsapi.models import VolvoCarsValueField + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType, json_loads_object + +from tests.common import async_load_fixture + +_MODEL_SPECIFIC_RESPONSES = { + "ex30_2024": ["energy_capabilities", "energy_state", "statistics", "vehicle"], + "s90_diesel_2018": ["diagnostics", "statistics", "vehicle"], + "xc40_electric_2024": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], + "xc90_petrol_2019": ["commands", "statistics", "vehicle"], +} + + +async def async_load_fixture_as_json( + hass: HomeAssistant, name: str, model: str +) -> JsonObjectType: + """Load a JSON object from a fixture.""" + if name in _MODEL_SPECIFIC_RESPONSES[model]: + name = f"{model}/{name}" + + fixture = await async_load_fixture(hass, f"{name}.json", DOMAIN) + return json_loads_object(fixture) + + +async def async_load_fixture_as_value_field( + hass: HomeAssistant, name: str, model: str +) -> dict[str, VolvoCarsValueField]: + """Load a `VolvoCarsValueField` object from a fixture.""" + data = await async_load_fixture_as_json(hass, name, model) + return {key: VolvoCarsValueField.from_dict(value) for key, value in data.items()} + + +def configure_mock( + mock: AsyncMock, *, return_value: Any = None, side_effect: Any = None +) -> None: + """Reconfigure mock.""" + mock.reset_mock() + mock.side_effect = side_effect + mock.return_value = return_value diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py new file mode 100644 index 00000000000..edd3f39998e --- /dev/null +++ b/tests/components/volvo/conftest.py @@ -0,0 +1,185 @@ +"""Define fixtures for Volvo unit tests.""" + +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import ( + VolvoCarsAvailableCommand, + VolvoCarsLocation, + VolvoCarsValueField, + VolvoCarsVehicle, +) + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import async_load_fixture_as_json, async_load_fixture_as_value_field +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + MOCK_ACCESS_TOKEN, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(params=[DEFAULT_MODEL]) +def full_model(request: pytest.FixtureRequest) -> str: + """Define which model to use when running the test. Use as a decorator.""" + return request.param + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DEFAULT_VIN, + data={ + "auth_implementation": DOMAIN, + CONF_API_KEY: DEFAULT_API_KEY, + CONF_VIN: DEFAULT_VIN, + CONF_TOKEN: { + "access_token": MOCK_ACCESS_TOKEN, + "refresh_token": "mock-refresh-token", + "expires_at": 123456789, + }, + }, + ) + + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(autouse=True) +async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]: + """Mock the Volvo API.""" + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + autospec=True, + ) as mock_api: + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + commands_data = ( + await async_load_fixture_as_json(hass, "commands", full_model) + ).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + + location_data = await async_load_fixture_as_json(hass, "location", full_model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = await async_load_fixture_as_value_field( + hass, "availability", full_model + ) + brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) + diagnostics = await async_load_fixture_as_value_field( + hass, "diagnostics", full_model + ) + doors = await async_load_fixture_as_value_field(hass, "doors", full_model) + energy_capabilities = await async_load_fixture_as_json( + hass, "energy_capabilities", full_model + ) + energy_state_data = await async_load_fixture_as_json( + hass, "energy_state", full_model + ) + energy_state = { + key: VolvoCarsValueField.from_dict(value) + for key, value in energy_state_data.items() + } + engine_status = await async_load_fixture_as_value_field( + hass, "engine_status", full_model + ) + engine_warnings = await async_load_fixture_as_value_field( + hass, "engine_warnings", full_model + ) + fuel_status = await async_load_fixture_as_value_field( + hass, "fuel_status", full_model + ) + odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) + recharge_status = await async_load_fixture_as_value_field( + hass, "recharge_status", full_model + ) + statistics = await async_load_fixture_as_value_field( + hass, "statistics", full_model + ) + tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) + warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) + windows = await async_load_fixture_as_value_field(hass, "windows", full_model) + + api: VolvoCarsApi = mock_api.return_value + api.async_get_brakes_status = AsyncMock(return_value=brakes) + api.async_get_command_accessibility = AsyncMock(return_value=availability) + api.async_get_commands = AsyncMock(return_value=commands) + api.async_get_diagnostics = AsyncMock(return_value=diagnostics) + api.async_get_doors_status = AsyncMock(return_value=doors) + api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities) + api.async_get_energy_state = AsyncMock(return_value=energy_state) + api.async_get_engine_status = AsyncMock(return_value=engine_status) + api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) + api.async_get_fuel_status = AsyncMock(return_value=fuel_status) + api.async_get_location = AsyncMock(return_value=location) + api.async_get_odometer = AsyncMock(return_value=odometer) + api.async_get_recharge_status = AsyncMock(return_value=recharge_status) + api.async_get_statistics = AsyncMock(return_value=statistics) + api.async_get_tyre_states = AsyncMock(return_value=tyres) + api.async_get_vehicle_details = AsyncMock(return_value=vehicle) + api.async_get_warnings = AsyncMock(return_value=warnings) + api.async_get_window_states = AsyncMock(return_value=windows) + + yield api + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.volvo.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/volvo/const.py b/tests/components/volvo/const.py new file mode 100644 index 00000000000..df18bacb2b0 --- /dev/null +++ b/tests/components/volvo/const.py @@ -0,0 +1,19 @@ +"""Define const for Volvo unit tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +DEFAULT_API_KEY = "abcdef0123456879abcdef" +DEFAULT_MODEL = "xc40_electric_2024" +DEFAULT_VIN = "YV1ABCDEFG1234567" + +MOCK_ACCESS_TOKEN = "mock-access-token" + +REDIRECT_URI = "https://example.com/auth/external/callback" + +SERVER_TOKEN_RESPONSE = { + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", + "token_type": "Bearer", + "expires_in": 60, +} diff --git a/tests/components/volvo/fixtures/availability.json b/tests/components/volvo/fixtures/availability.json new file mode 100644 index 00000000000..264f4d54360 --- /dev/null +++ b/tests/components/volvo/fixtures/availability.json @@ -0,0 +1,6 @@ +{ + "availabilityStatus": { + "value": "AVAILABLE", + "timestamp": "2024-12-30T14:32:26.169Z" + } +} diff --git a/tests/components/volvo/fixtures/brakes.json b/tests/components/volvo/fixtures/brakes.json new file mode 100644 index 00000000000..6fe3b3b328c --- /dev/null +++ b/tests/components/volvo/fixtures/brakes.json @@ -0,0 +1,6 @@ +{ + "brakeFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/commands.json b/tests/components/volvo/fixtures/commands.json new file mode 100644 index 00000000000..5d21861801f --- /dev/null +++ b/tests/components/volvo/fixtures/commands.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/diagnostics.json b/tests/components/volvo/fixtures/diagnostics.json new file mode 100644 index 00000000000..100af71b9e3 --- /dev/null +++ b/tests/components/volvo/fixtures/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 23, + "unit": "months", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/doors.json b/tests/components/volvo/fixtures/doors.json new file mode 100644 index 00000000000..268d9fec467 --- /dev/null +++ b/tests/components/volvo/fixtures/doors.json @@ -0,0 +1,34 @@ +{ + "centralLock": { + "value": "LOCKED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "frontRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearLeftDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "rearRightDoor": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "hood": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tailgate": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + }, + "tankLid": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:20:20.570Z" + } +} diff --git a/tests/components/volvo/fixtures/energy_capabilities.json b/tests/components/volvo/fixtures/energy_capabilities.json new file mode 100644 index 00000000000..16ba914e343 --- /dev/null +++ b/tests/components/volvo/fixtures/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": false, + "batteryChargeLevel": { + "isSupported": false + }, + "electricRange": { + "isSupported": false + }, + "chargerConnectionStatus": { + "isSupported": false + }, + "chargingSystemStatus": { + "isSupported": false + }, + "chargingType": { + "isSupported": false + }, + "chargerPowerStatus": { + "isSupported": false + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": false + }, + "targetBatteryChargeLevel": { + "isSupported": false + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/energy_state.json b/tests/components/volvo/fixtures/energy_state.json new file mode 100644 index 00000000000..31d717c4cce --- /dev/null +++ b/tests/components/volvo/fixtures/energy_state.json @@ -0,0 +1,42 @@ +{ + "batteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "electricRange": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerConnectionStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingType": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargerPowerStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED" + } +} diff --git a/tests/components/volvo/fixtures/engine_status.json b/tests/components/volvo/fixtures/engine_status.json new file mode 100644 index 00000000000..daac36b6a26 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_status.json @@ -0,0 +1,6 @@ +{ + "engineStatus": { + "value": "STOPPED", + "timestamp": "2024-12-30T15:00:00.000Z" + } +} diff --git a/tests/components/volvo/fixtures/engine_warnings.json b/tests/components/volvo/fixtures/engine_warnings.json new file mode 100644 index 00000000000..d431355fd24 --- /dev/null +++ b/tests/components/volvo/fixtures/engine_warnings.json @@ -0,0 +1,10 @@ +{ + "oilLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineCoolantLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json new file mode 100644 index 00000000000..968c759ab27 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": true + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_state.json b/tests/components/volvo/fixtures/ex30_2024/energy_state.json new file mode 100644 index 00000000000..fe42dba568a --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/energy_state.json @@ -0,0 +1,57 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 38, + "unit": "percentage", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "electricRange": { + "status": "OK", + "value": 90, + "unit": "km", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingType": { + "status": "OK", + "value": "NONE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "NO_POWER_AVAILABLE", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 0, + "unit": "minutes", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingCurrentLimit": { + "status": "OK", + "value": 32, + "unit": "ampere", + "updatedAt": "2024-03-05T08:38:44Z" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "ERROR", + "code": "PROPERTY_NOT_FOUND", + "message": "No valid value could be found for the requested property" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/statistics.json b/tests/components/volvo/fixtures/ex30_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/ex30_2024/vehicle.json b/tests/components/volvo/fixtures/ex30_2024/vehicle.json new file mode 100644 index 00000000000..dc47b5bb341 --- /dev/null +++ b/tests/components/volvo/fixtures/ex30_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "NONE", + "externalColour": "Crystal White Pearl", + "batteryCapacityKWH": 66.0, + "images": { + "exteriorImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/exterior/studio/right/transparent_exterior-studio-right_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920", + "internalImageUrl": "https://wizz.volvocars.com/images/2024/123/v2/interior/studio/side/interior-studio-side_0000000000000000000000000000000000000000.png?client=public-api-engineering&w=1920" + }, + "descriptions": { + "model": "EX30", + "upholstery": "R310", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/fuel_status.json b/tests/components/volvo/fixtures/fuel_status.json new file mode 100644 index 00000000000..a55f14467fe --- /dev/null +++ b/tests/components/volvo/fixtures/fuel_status.json @@ -0,0 +1,12 @@ +{ + "fuelAmount": { + "value": "47.3", + "unit": "l", + "timestamp": "2020-11-19T21:23:24.123Z" + }, + "batteryChargeLevel": { + "value": "87.3", + "unit": "%", + "timestamp": "2020-11-19T21:23:24.123Z" + } +} diff --git a/tests/components/volvo/fixtures/location.json b/tests/components/volvo/fixtures/location.json new file mode 100644 index 00000000000..eec49f8a66b --- /dev/null +++ b/tests/components/volvo/fixtures/location.json @@ -0,0 +1,11 @@ +{ + "type": "Feature", + "properties": { + "timestamp": "2024-12-30T15:00:00.000Z", + "heading": "90" + }, + "geometry": { + "type": "Point", + "coordinates": [11.849843629550225, 57.72537482589284, 0.0] + } +} diff --git a/tests/components/volvo/fixtures/odometer.json b/tests/components/volvo/fixtures/odometer.json new file mode 100644 index 00000000000..a9196faaa7d --- /dev/null +++ b/tests/components/volvo/fixtures/odometer.json @@ -0,0 +1,7 @@ +{ + "odometer": { + "value": 30000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/recharge_status.json b/tests/components/volvo/fixtures/recharge_status.json new file mode 100644 index 00000000000..5e9fed0803c --- /dev/null +++ b/tests/components/volvo/fixtures/recharge_status.json @@ -0,0 +1,25 @@ +{ + "estimatedChargingTime": { + "value": "780", + "unit": "minutes", + "timestamp": "2024-12-30T14:30:08Z" + }, + "batteryChargeLevel": { + "value": "58.0", + "unit": "percentage", + "timestamp": "2024-12-30T14:30:08Z" + }, + "electricRange": { + "value": "250", + "unit": "kilometers", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingSystemStatus": { + "value": "CHARGING_SYSTEM_IDLE", + "timestamp": "2024-12-30T14:30:08Z" + }, + "chargingConnectionStatus": { + "value": "CONNECTION_STATUS_CONNECTED_AC", + "timestamp": "2024-12-30T14:30:08Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json new file mode 100644 index 00000000000..738eb3c8966 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/diagnostics.json @@ -0,0 +1,25 @@ +{ + "serviceWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "engineHoursToService": { + "value": 1266, + "unit": "h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToService": { + "value": 29000, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "washerFluidLevelWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "timeToService": { + "value": 17, + "unit": "days", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json new file mode 100644 index 00000000000..9f6760451ed --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 7.23, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 147, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json new file mode 100644 index 00000000000..429964991e7 --- /dev/null +++ b/tests/components/volvo/fixtures/s90_diesel_2018/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2018, + "gearbox": "AUTOMATIC", + "fuelType": "DIESEL", + "externalColour": "Electric Silver", + "images": { + "exteriorImageUrl": "", + "internalImageUrl": "" + }, + "descriptions": { + "model": "S90", + "upholstery": "null", + "steering": "RIGHT" + } +} diff --git a/tests/components/volvo/fixtures/tyres.json b/tests/components/volvo/fixtures/tyres.json new file mode 100644 index 00000000000..c414c85203f --- /dev/null +++ b/tests/components/volvo/fixtures/tyres.json @@ -0,0 +1,18 @@ +{ + "frontLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "frontRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearLeft": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "rearRight": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/warnings.json b/tests/components/volvo/fixtures/warnings.json new file mode 100644 index 00000000000..5bec30ed4b3 --- /dev/null +++ b/tests/components/volvo/fixtures/warnings.json @@ -0,0 +1,94 @@ +{ + "brakeLightCenterWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "brakeLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightFrontWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "fogLightRearWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "positionLightRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "highBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "lowBeamRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "daytimeRunningLightRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationFrontRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearLeftWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "turnIndicationRearRightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "registrationPlateLightWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "sideMarkLightsWarning": { + "value": "NO_WARNING", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "hazardLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "reverseLightsWarning": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/windows.json b/tests/components/volvo/fixtures/windows.json new file mode 100644 index 00000000000..cd399b3bbe8 --- /dev/null +++ b/tests/components/volvo/fixtures/windows.json @@ -0,0 +1,22 @@ +{ + "frontLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "frontRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearLeftWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "rearRightWindow": { + "value": "CLOSED", + "timestamp": "2024-12-30T14:28:12.202Z" + }, + "sunroof": { + "value": "UNSPECIFIED", + "timestamp": "2024-12-30T14:28:12.202Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json new file mode 100644 index 00000000000..968c759ab27 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": true + }, + "electricRange": { + "isSupported": true + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": true + }, + "chargerPowerStatus": { + "isSupported": true + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": true + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": true + }, + "chargingPower": { + "isSupported": true + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json new file mode 100644 index 00000000000..16208571c47 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json @@ -0,0 +1,58 @@ +{ + "batteryChargeLevel": { + "status": "OK", + "value": 53, + "unit": "percentage", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "electricRange": { + "status": "OK", + "value": 220, + "unit": "km", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "CONNECTED", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingStatus": { + "status": "OK", + "value": "CHARGING", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingType": { + "status": "OK", + "value": "AC", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargerPowerStatus": { + "status": "OK", + "value": "PROVIDING_POWER", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "OK", + "value": 1440, + "unit": "minutes", + "updatedAt": "2025-07-02T08:51:23Z" + }, + "chargingCurrentLimit": { + "status": "OK", + "value": 32, + "unit": "ampere", + "updatedAt": "2024-03-05T08:38:44Z" + }, + "targetBatteryChargeLevel": { + "status": "OK", + "value": 90, + "unit": "percentage", + "updatedAt": "2024-09-22T09:40:12Z" + }, + "chargingPower": { + "status": "OK", + "value": 1386, + "unit": "watts", + "updatedAt": "2025-07-02T08:51:23Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json new file mode 100644 index 00000000000..9e2f32bdcf2 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/statistics.json @@ -0,0 +1,32 @@ +{ + "averageEnergyConsumption": { + "value": 22.6, + "unit": "kWh/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 53, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 26, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterManual": { + "value": 3822.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 18.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyBattery": { + "value": 250, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + } +} diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json new file mode 100644 index 00000000000..8b36c06f681 --- /dev/null +++ b/tests/components/volvo/fixtures/xc40_electric_2024/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2024, + "gearbox": "AUTOMATIC", + "fuelType": "ELECTRIC", + "externalColour": "Silver Dawn", + "batteryCapacityKWH": 81.608, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/exterior-v4/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY24_0000/123/interior-v4/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC40", + "upholstery": "null", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json new file mode 100644 index 00000000000..8f5e62df1ed --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/commands.json @@ -0,0 +1,44 @@ +{ + "data": [ + { + "command": "LOCK_REDUCED_GUARD", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock-reduced-guard" + }, + { + "command": "LOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/lock" + }, + { + "command": "UNLOCK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/unlock" + }, + { + "command": "HONK", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk" + }, + { + "command": "HONK_AND_FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/honk-flash" + }, + { + "command": "FLASH", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/flash" + }, + { + "command": "CLIMATIZATION_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-start" + }, + { + "command": "CLIMATIZATION_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/climatization-stop" + }, + { + "command": "ENGINE_START", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-start" + }, + { + "command": "ENGINE_STOP", + "href": "/v2/vehicles/YV1ABCDEFG1234567/commands/engine-stop" + } + ] +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json new file mode 100644 index 00000000000..1a7744a4d49 --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 9.59, + "unit": "l/100km", + "timestamp": "2024-12-30T14:53:44.785Z" + }, + "averageSpeed": { + "value": 66, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "averageSpeedAutomatic": { + "value": 77, + "unit": "km/h", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "distanceToEmptyTank": { + "value": 253, + "unit": "km", + "timestamp": "2024-12-30T14:30:08.338Z" + }, + "tripMeterManual": { + "value": 178.9, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + }, + "tripMeterAutomatic": { + "value": 4.2, + "unit": "km", + "timestamp": "2024-12-30T14:18:56.849Z" + } +} diff --git a/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json new file mode 100644 index 00000000000..1d4b1250b8a --- /dev/null +++ b/tests/components/volvo/fixtures/xc90_petrol_2019/vehicle.json @@ -0,0 +1,16 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2019, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL", + "externalColour": "Passion Red Solid", + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/exterior/MY17_0000/123/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/vbsnext-v4/interior/MY17_0000/123/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC90", + "upholstery": "CHARCOAL/LEABR/CHARC/S", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0f79ab5ca07 --- /dev/null +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -0,0 +1,3833 @@ +# serializer version: 1 +# name: test_sensor[ex30_2024][sensor.volvo_ex30_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.volvo_ex30_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo EX30 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-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.volvo_ex30_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo EX30 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66.0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_connection_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 connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-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.volvo_ex30_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging limit', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current_limit', + 'unique_id': 'yv1abcdefg1234567_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Volvo EX30 Charging limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_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.volvo_ex30_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo EX30 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_power_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 power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging power status', + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_power_available', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_ex30_charging_type', + '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 type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo EX30 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_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.volvo_ex30_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-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.volvo_ex30_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-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.volvo_ex30_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-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.volvo_ex30_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-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.volvo_ex30_target_battery_charge_level', + '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': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-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.volvo_ex30_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-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.volvo_ex30_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo EX30 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-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.volvo_ex30_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-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.volvo_ex30_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-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.volvo_ex30_trip_manual_average_energy_consumption', + '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': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo EX30 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-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.volvo_ex30_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo EX30 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-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.volvo_ex30_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[ex30_2024][sensor.volvo_ex30_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo EX30 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_ex30_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_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.volvo_s90_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo S90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_s90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo S90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-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.volvo_s90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '147', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-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.volvo_s90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-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.volvo_s90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo S90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-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.volvo_s90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-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.volvo_s90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-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.volvo_s90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo S90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-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.volvo_s90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-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.volvo_s90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-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.volvo_s90_trip_manual_average_fuel_consumption', + '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': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo S90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.23', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-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.volvo_s90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo S90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-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.volvo_s90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[s90_diesel_2018][sensor.volvo_s90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo S90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_s90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_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.volvo_xc40_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC40 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-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.volvo_xc40_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC40 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '81.608', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_connection_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 connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-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.volvo_xc40_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging limit', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current_limit', + 'unique_id': 'yv1abcdefg1234567_charging_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Volvo XC40 Charging limit', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_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.volvo_xc40_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'yv1abcdefg1234567_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Volvo XC40 Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_power_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 power status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power_status', + 'unique_id': 'yv1abcdefg1234567_charging_power_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_power_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging power status', + 'options': list([ + 'providing_power', + 'no_power_available', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_power_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'providing_power', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc40_charging_type', + '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 type', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_type', + 'unique_id': 'yv1abcdefg1234567_charging_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_charging_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC40 Charging type', + 'options': list([ + 'ac', + 'dc', + 'none', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_charging_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ac', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_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.volvo_xc40_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-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.volvo_xc40_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-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.volvo_xc40_estimated_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Estimated charging time', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_charging_time', + 'unique_id': 'yv1abcdefg1234567_estimated_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_estimated_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Estimated charging time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_estimated_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1440', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-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.volvo_xc40_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-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.volvo_xc40_target_battery_charge_level', + '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': 'Target battery charge level', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_battery_charge_level', + 'unique_id': 'yv1abcdefg1234567_target_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_target_battery_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Target battery charge level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_target_battery_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-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.volvo_xc40_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-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.volvo_xc40_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC40 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-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.volvo_xc40_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-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.volvo_xc40_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-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.volvo_xc40_trip_manual_average_energy_consumption', + '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': 'Trip manual average energy consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_energy_consumption', + 'unique_id': 'yv1abcdefg1234567_average_energy_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC40 Trip manual average energy consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-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.volvo_xc40_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC40 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-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.volvo_xc40_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC40 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc40_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3822.9', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_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.volvo_xc90_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC90 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC90 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-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.volvo_xc90_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '253', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-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.volvo_xc90_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-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.volvo_xc90_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC90 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-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.volvo_xc90_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-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.volvo_xc90_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-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.volvo_xc90_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC90 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-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.volvo_xc90_trip_automatic_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed_automatic', + 'unique_id': 'yv1abcdefg1234567_average_speed_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip automatic average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-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.volvo_xc90_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.2', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-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.volvo_xc90_trip_manual_average_fuel_consumption', + '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': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.59', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-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.volvo_xc90_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC90 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-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.volvo_xc90_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC90 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc90_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '178.9', + }) +# --- diff --git a/tests/components/volvo/test_config_flow.py b/tests/components/volvo/test_config_flow.py new file mode 100644 index 00000000000..91a7803dce5 --- /dev/null +++ b/tests/components/volvo/test_config_flow.py @@ -0,0 +1,303 @@ +"""Test the Volvo config flow.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import AUTHORIZE_URL, TOKEN_URL +from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle +from volvocarsapi.scopes import DEFAULT_SCOPES +from yarl import URL + +from homeassistant import config_entries +from homeassistant.components.volvo.const import CONF_VIN, DOMAIN +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import async_load_fixture_as_json, configure_mock +from .const import ( + CLIENT_ID, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DEFAULT_VIN, + REDIRECT_URI, + SERVER_TOKEN_RESPONSE, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check full flow.""" + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_API_KEY] == DEFAULT_API_KEY + assert result["data"][CONF_VIN] == DEFAULT_VIN + assert result["context"]["unique_id"] == DEFAULT_VIN + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_single_vin_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_setup_entry: AsyncMock, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API returns a single VIN.""" + _configure_mock_vehicles_success(mock_config_flow_api, single_vin=True) + + # Since there is only one VIN, the api_key step is the only step + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize(("api_key_failure"), [pytest.param(True), pytest.param(False)]) +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + mock_config_flow_api: VolvoCarsApi, + api_key_failure: bool, +) -> None: + """Test reauthentication flow.""" + 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"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await _async_run_flow_to_completion( + hass, + result, + mock_config_flow_api, + has_vin_step=False, + is_reauth=True, + api_key_failure=api_key_failure, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test reconfiguration flow.""" + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("current_request_with_host", "mock_config_entry") +async def test_unique_id_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Test unique ID flow.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await _async_run_flow_to_completion( + hass, config_flow, mock_config_flow_api + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_api_failure_flow( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, +) -> None: + """Check flow where API throws an exception.""" + _configure_mock_vehicles_failure(mock_config_flow_api) + + result = await hass.config_entries.flow.async_configure(config_flow["flow_id"]) + assert result["step_id"] == "api_key" + + result = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_load_vehicles" + assert result["step_id"] == "api_key" + + result = await _async_run_flow_to_completion( + hass, result, mock_config_flow_api, configure=False + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.fixture +async def config_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> config_entries.ConfigFlowResult: + """Initialize a new config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == AUTHORIZE_URL + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert result_url.query["redirect_uri"] == REDIRECT_URI + assert result_url.query["state"] == state + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" + assert result_url.query["scope"] == " ".join(DEFAULT_SCOPES) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + return result + + +@pytest.fixture +async def mock_config_flow_api(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock API used in config flow.""" + with patch( + "homeassistant.components.volvo.config_flow.VolvoCarsApi", + autospec=True, + ) as mock_api: + api: VolvoCarsApi = mock_api.return_value + + _configure_mock_vehicles_success(api) + + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", DEFAULT_MODEL) + configure_mock( + api.async_get_vehicle_details, + return_value=VolvoCarsVehicle.from_dict(vehicle_data), + ) + + yield api + + +@pytest.fixture(autouse=True) +async def mock_auth_client( + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[AsyncMock]: + """Mock auth requests.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + TOKEN_URL, + json=SERVER_TOKEN_RESPONSE, + ) + + +async def _async_run_flow_to_completion( + hass: HomeAssistant, + config_flow: ConfigFlowResult, + mock_config_flow_api: VolvoCarsApi, + *, + configure: bool = True, + has_vin_step: bool = True, + is_reauth: bool = False, + api_key_failure: bool = False, +) -> ConfigFlowResult: + if configure: + if api_key_failure: + _configure_mock_vehicles_failure(mock_config_flow_api) + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"] + ) + + if is_reauth and not api_key_failure: + return config_flow + + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "api_key" + + _configure_mock_vehicles_success(mock_config_flow_api) + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_API_KEY: "abcdef0123456879abcdef"} + ) + + if has_vin_step: + assert config_flow["type"] is FlowResultType.FORM + assert config_flow["step_id"] == "vin" + + config_flow = await hass.config_entries.flow.async_configure( + config_flow["flow_id"], {CONF_VIN: DEFAULT_VIN} + ) + + return config_flow + + +def _configure_mock_vehicles_success( + mock_config_flow_api: VolvoCarsApi, single_vin: bool = False +) -> None: + vins = [{"vin": DEFAULT_VIN}] + + if not single_vin: + vins.append({"vin": "YV10000000AAAAAAA"}) + + configure_mock(mock_config_flow_api.async_get_vehicles, return_value=vins) + + +def _configure_mock_vehicles_failure(mock_config_flow_api: VolvoCarsApi) -> None: + configure_mock( + mock_config_flow_api.async_get_vehicles, side_effect=VolvoApiException() + ) diff --git a/tests/components/volvo/test_coordinator.py b/tests/components/volvo/test_coordinator.py new file mode 100644 index 00000000000..271693a18d1 --- /dev/null +++ b/tests/components/volvo/test_coordinator.py @@ -0,0 +1,151 @@ +"""Test Volvo coordinator.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.models import ( + VolvoApiException, + VolvoAuthException, + VolvoCarsValueField, +) + +from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import configure_mock + +from tests.common import async_fire_time_changed + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator update.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + value["odometer"].value = 30001 + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30001" + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_coordinator_with_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test coordinator with errors.""" + assert await setup_integration() + + sensor_id = "sensor.volvo_xc40_odometer" + interval = timedelta(minutes=VERY_SLOW_INTERVAL) + value = {"odometer": VolvoCarsValueField(value=30000, unit="km")} + mock_method: AsyncMock = mock_api.async_get_odometer + + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoApiException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=Exception()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + configure_mock(mock_method, return_value=value) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == "30000" + + configure_mock(mock_method, side_effect=VolvoAuthException()) + freezer.tick(interval) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_method.call_count == 1 + state = hass.states.get(sensor_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00") +async def test_update_coordinator_all_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test API returning error for all calls during coordinator update.""" + assert await setup_integration() + + _mock_api_failure(mock_api) + freezer.tick(timedelta(minutes=VERY_SLOW_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + for state in hass.states.async_all(): + assert state.state == STATE_UNAVAILABLE + + +def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock: + """Mock the Volvo API so that it raises an exception for all calls.""" + + mock_api.async_get_brakes_status.side_effect = VolvoApiException() + mock_api.async_get_command_accessibility.side_effect = VolvoApiException() + mock_api.async_get_commands.side_effect = VolvoApiException() + mock_api.async_get_diagnostics.side_effect = VolvoApiException() + mock_api.async_get_doors_status.side_effect = VolvoApiException() + mock_api.async_get_energy_capabilities.side_effect = VolvoApiException() + mock_api.async_get_energy_state.side_effect = VolvoApiException() + mock_api.async_get_engine_status.side_effect = VolvoApiException() + mock_api.async_get_engine_warnings.side_effect = VolvoApiException() + mock_api.async_get_fuel_status.side_effect = VolvoApiException() + mock_api.async_get_location.side_effect = VolvoApiException() + mock_api.async_get_odometer.side_effect = VolvoApiException() + mock_api.async_get_recharge_status.side_effect = VolvoApiException() + mock_api.async_get_statistics.side_effect = VolvoApiException() + mock_api.async_get_tyre_states.side_effect = VolvoApiException() + mock_api.async_get_warnings.side_effect = VolvoApiException() + mock_api.async_get_window_states.side_effect = VolvoApiException() + + return mock_api diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py new file mode 100644 index 00000000000..e0e6c74b839 --- /dev/null +++ b/tests/components/volvo/test_init.py @@ -0,0 +1,125 @@ +"""Test Volvo init.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import AsyncMock + +import pytest +from volvocarsapi.api import VolvoCarsApi +from volvocarsapi.auth import TOKEN_URL +from volvocarsapi.models import VolvoAuthException + +from homeassistant.components.volvo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from . import configure_mock +from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test setting up the integration.""" + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_token_refresh_success( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh succeeds.""" + + assert mock_config_entry.data[CONF_TOKEN]["access_token"] == MOCK_ACCESS_TOKEN + + assert await setup_integration() + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Verify token + assert len(aioclient_mock.mock_calls) == 1 + assert ( + mock_config_entry.data[CONF_TOKEN]["access_token"] + == SERVER_TOKEN_RESPONSE["access_token"] + ) + + +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.FORBIDDEN), + (HTTPStatus.INTERNAL_SERVER_ERROR), + (HTTPStatus.NOT_FOUND), + ], +) +async def test_token_refresh_fail( + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, +) -> None: + """Test where token refresh fails.""" + + aioclient_mock.post(TOKEN_URL, status=token_response) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_token_refresh_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test where token refresh indicates unauthorized.""" + + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert flows + assert flows[0]["handler"] == DOMAIN + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_no_vehicle( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test no vehicle during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=None) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_vehicle_auth_failure( + mock_config_entry: MockConfigEntry, + setup_integration: Callable[[], Awaitable[bool]], + mock_api: VolvoCarsApi, +) -> None: + """Test auth failure during coordinator setup.""" + mock_method: AsyncMock = mock_api.async_get_vehicle_details + + configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException()) + assert not await setup_integration() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py new file mode 100644 index 00000000000..f610ee2ed57 --- /dev/null +++ b/tests/components/volvo/test_sensor.py @@ -0,0 +1,32 @@ +"""Test Volvo sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "full_model", + ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], +) +async def test_sensor( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e518e7beaca1f14ad104b9ed8fec84e1d5246d8a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:42:18 +0200 Subject: [PATCH 0706/1118] Add service tests to Tuya select platform (#149156) Co-authored-by: Joost Lekkerkerker --- tests/components/tuya/test_select.py | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index c295a07d83f..cd1d926ff76 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -8,9 +8,14 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +58,62 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "forward", + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "control_back_mode", "value": "forward"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_invalid_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "hello", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_option" From 92ad922ddc33527456f76aa87ed493d53fdf8426 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:42:36 +0200 Subject: [PATCH 0707/1118] Add fan mode support for Tuya air conditioner (aqoouq7x) (#149226) --- homeassistant/components/tuya/climate.py | 2 +- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/wk_air_conditioner.json | 102 ++++++++++++++++++ .../tuya/snapshots/test_climate.ambr | 81 ++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++ 5 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/wk_air_conditioner.json diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 370548d67b0..c8071e68397 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -252,7 +252,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): # Determine fan modes self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( - (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), dptype=DPType.ENUM, prefer_function=True, ): diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 632d05ce931..ab2d28ef645 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "wk_air_conditioner": [ + # https://github.com/home-assistant/core/issues/146263 + Platform.CLIMATE, + Platform.SWITCH, + ], "ydkt_dolceclima_unsupported": [ # https://github.com/orgs/home-assistant/discussions/288 # unsupported device - no platforms diff --git a/tests/components/tuya/fixtures/wk_air_conditioner.json b/tests/components/tuya/fixtures/wk_air_conditioner.json new file mode 100644 index 00000000000..2c162a1a514 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_air_conditioner.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1749538552551GHfV17", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6fc1645146455a2efrex", + "name": "Clima cucina", + "category": "wk", + "product_id": "aqoouq7x", + "product_name": "T7-Air conditioner thermostat\uff08ZIGBEE)", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-04-21T13:39:47+00:00", + "create_time": "2025-04-21T13:39:47+00:00", + "update_time": "2025-04-21T13:39:47+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "mode": "cold", + "temp_set": 25, + "temp_current": 27, + "level": "auto", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 42fc10fef54..6e93a1b263c 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.clima_cucina', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf6fc1645146455a2efrex', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27.0, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'friendly_name': 'Clima cucina', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.clima_cucina', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index dc47486e980..92243414892 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1161,6 +1161,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-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': , + 'entity_id': 'switch.clima_cucina_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf6fc1645146455a2efrexchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clima cucina Child lock', + }), + 'context': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From aa1314c1d549af03589b25ff468e119449e4c09e Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 28 Jul 2025 23:43:20 +0800 Subject: [PATCH 0708/1118] Add YoLink YS6614 support. (#149153) --- homeassistant/components/yolink/const.py | 2 ++ homeassistant/components/yolink/sensor.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9556c1bbd82..851b65e1a15 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -32,6 +32,8 @@ DEV_MODEL_FLEX_FOB_YS3614_UC = "YS3614-UC" DEV_MODEL_FLEX_FOB_YS3614_EC = "YS3614-EC" DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" +DEV_MODEL_PLUG_YS6614_UC = "YS6614-UC" +DEV_MODEL_PLUG_YS6614_EC = "YS6614-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 37cd763194d..5425c242821 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -58,6 +58,8 @@ from homeassistant.util import percentage from .const import ( DEV_MODEL_PLUG_YS6602_EC, DEV_MODEL_PLUG_YS6602_UC, + DEV_MODEL_PLUG_YS6614_EC, + DEV_MODEL_PLUG_YS6614_UC, DEV_MODEL_PLUG_YS6803_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_TH_SENSOR_YS8004_EC, @@ -152,6 +154,8 @@ NONE_HUMIDITY_SENSOR_MODELS = [ POWER_SUPPORT_MODELS = [ DEV_MODEL_PLUG_YS6602_UC, DEV_MODEL_PLUG_YS6602_EC, + DEV_MODEL_PLUG_YS6614_UC, + DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_PLUG_YS6803_EC, ] @@ -319,6 +323,15 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], should_update_entity=lambda value: value is not None, ), + YoLinkSensorEntityDescription( + key="coreTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_model_name + in [DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6614_UC], + should_update_entity=lambda value: value is not None, + ), ) From 8339516fb4eb829a1b55bcb81d06ecea7250fa09 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:44:43 -0400 Subject: [PATCH 0709/1118] Add optimistic option to alarm control panel yaml (#149334) --- .../template/alarm_control_panel.py | 16 +++----- .../template/test_alarm_control_panel.py | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index f95fc0dbab7..9bcb656e4aa 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -49,6 +49,7 @@ from .helpers import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -113,8 +114,8 @@ ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( ) ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( - make_template_entity_common_modern_schema(DEFAULT_NAME).schema -) + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { @@ -205,13 +206,12 @@ class AbstractTemplateAlarmControlPanel( """Representation of a templated Alarm Control Panel features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called """Initialize the features.""" - self._template = config.get(CONF_STATE) - self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value @@ -273,18 +273,14 @@ class AbstractTemplateAlarmControlPanel( async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" - optimistic_set = False - - if self._template is None: - self._state = state - optimistic_set = True if script: await self.async_run_script( script, run_variables={ATTR_CODE: code}, context=self._context ) - if optimistic_set: + if self._attr_assumed_state: + self._state = state self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 06d678edcab..c1df654e328 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -932,3 +932,44 @@ async def test_flow_preview( ) assert state["state"] == AlarmControlPanelState.DISARMED + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_AWAY + + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_HOME From 5af4290b7753831f895c9da13cca5c0143421ea9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 28 Jul 2025 19:33:39 +0300 Subject: [PATCH 0710/1118] Update IQS for Alexa Devices (#149440) --- homeassistant/components/alexa_devices/quality_scale.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 6b1d084b842..47ff53dd04e 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -51,14 +51,14 @@ rules: docs-known-limitations: todo docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: From b1dd742a57b3eb52788b660a907821efa5b243ab Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:49:12 +0200 Subject: [PATCH 0711/1118] Move battery properties from legacy Ecovacs vacuum entity to separate entities (#149084) --- .../components/ecovacs/binary_sensor.py | 51 +++++++++++++++++- homeassistant/components/ecovacs/sensor.py | 50 +++++++++++++++++- homeassistant/components/ecovacs/vacuum.py | 24 +-------- .../ecovacs/snapshots/test_sensor.ambr | 52 +++++++++++++++++++ tests/components/ecovacs/test_init.py | 2 +- 5 files changed, 151 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 32bf5d3ba15..5997559c3cf 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -4,10 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.base import Event +from deebot_client.events import Event from deebot_client.events.water_info import MopAttachedEvent +from sucks import VacBot from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsLegacyEntity, +) from .util import get_supported_entities @@ -47,12 +53,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" + controller = config_entry.runtime_data + async_add_entities( get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) + legacy_entities = [] + for device in controller.legacy_devices: + if not controller.legacy_entity_is_added(device, "battery_charging"): + controller.add_legacy_entity(device, "battery_charging") + legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) + class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], @@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event]( self.async_write_ha_state() self._subscribe(self._capability.event, on_event) + + +class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity): + """Legacy battery charging sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_charging" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.statusEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if self.device.charge_status is None: + return None + return bool(self.device.is_charging) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index e84485228e4..b368b92a579 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -37,6 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry @@ -225,7 +226,7 @@ async def async_setup_entry( async_add_entities(entities) - async def _add_legacy_entities() -> None: + async def _add_legacy_lifespan_entities() -> None: entities = [] for device in controller.legacy_devices: for description in LEGACY_LIFESPAN_SENSORS: @@ -242,14 +243,21 @@ async def async_setup_entry( async_add_entities(entities) def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None: - hass.create_task(_add_legacy_entities()) + hass.create_task(_add_legacy_lifespan_entities()) + legacy_entities = [] for device in controller.legacy_devices: config_entry.async_on_unload( device.lifespanEvents.subscribe( _fire_ecovacs_legacy_lifespan_event ).unsubscribe ) + if not controller.legacy_entity_is_added(device, "battery_status"): + controller.add_legacy_entity(device, "battery_status") + legacy_entities.append(EcovacsLegacyBatterySensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) class EcovacsSensor( @@ -344,6 +352,44 @@ class EcovacsErrorSensor( self._subscribe(self._capability.event, on_event) +class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity): + """Legacy battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_status" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.batteryEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if (status := self.device.battery_status) is not None: + return status * 100 # type: ignore[no-any-return] + return None + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + return icon_for_battery_level( + battery_level=self.native_value, charging=self.device.is_charging + ) + + class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity): """Legacy Lifespan sensor.""" diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 6570b80e920..d432410c8c5 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from . import EcovacsConfigEntry @@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): lambda _: self.schedule_update_ha_state() ) ) - self._event_listeners.append( - self.device.batteryEvents.subscribe( - lambda _: self.schedule_update_ha_state() - ) - ) self._event_listeners.append( self.device.lifespanEvents.subscribe( lambda _: self.schedule_update_ha_state() @@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): return None - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - if self.device.battery_status is not None: - return self.device.battery_status * 100 # type: ignore[no-any-return] - - return None - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.device.is_charging - ) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index fcd043e10fa..c216c4c9e4a 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:entity-registry] + 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.e1234567890000000003_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000003_battery_status', + 'unit_of_measurement': '%', + }) +# --- +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'E1234567890000000003 Battery', + 'icon': 'mdi:battery-unknown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_legacy_sensors[123][sensor.e1234567890000000003_filter_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -148,6 +199,7 @@ # --- # name: test_legacy_sensors[123][states] list([ + 'sensor.e1234567890000000003_battery', 'sensor.e1234567890000000003_main_brush_lifespan', 'sensor.e1234567890000000003_side_brush_lifespan', 'sensor.e1234567890000000003_filter_lifespan', diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c0e5ce143c9..3115f1b4040 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 1), + ("123", 2), ], ) async def test_all_entities_loaded( From dda46e7e0bddcd46d2231bffedc89a128ab94c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 28 Jul 2025 18:38:06 +0100 Subject: [PATCH 0712/1118] Use non-autospec mock in Reolink's remaining tests (#149565) Co-authored-by: starkillerOG --- tests/components/reolink/conftest.py | 65 +++-------- tests/components/reolink/test_config_flow.py | 114 ++++++++----------- tests/components/reolink/test_select.py | 49 ++++---- tests/components/reolink/test_update.py | 50 ++++---- 4 files changed, 102 insertions(+), 176 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d699d1b9102..fa4cac6fff3 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -1,11 +1,10 @@ """Setup the Reolink tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -91,6 +90,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.expire_session = AsyncMock() host_mock.set_volume = AsyncMock() host_mock.set_hub_audio = AsyncMock() + host_mock.play_quick_reply = AsyncMock() + host_mock.update_firmware = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -155,6 +156,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.recording_packing_time = "60 Minutes" # Baichuan + host_mock.baichuan = MagicMock() host_mock.baichuan_only = False # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT @@ -163,6 +165,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.unsubscribe_events = AsyncMock() host_mock.baichuan.check_subscribe_events = AsyncMock() host_mock.baichuan.get_privacy_mode = AsyncMock() + host_mock.baichuan.set_privacy_mode = AsyncMock() + host_mock.baichuan.set_scene = AsyncMock() host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" @@ -180,38 +184,20 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_name.return_value = "zone1" -@pytest.fixture(scope="module") -def reolink_connect_class() -> Generator[MagicMock]: +@pytest.fixture +def reolink_host_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" - with ( - patch( - "homeassistant.components.reolink.host.Host", autospec=True - ) as host_mock_class, - ): - host_mock = host_mock_class.return_value - host_mock.baichuan = create_autospec(Baichuan) - _init_host_mock(host_mock) + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + _init_host_mock(host_mock_class.return_value) yield host_mock_class @pytest.fixture -def reolink_connect( - reolink_connect_class: MagicMock, -) -> Generator[MagicMock]: - """Mock reolink connection.""" - return reolink_connect_class.return_value - - -@pytest.fixture -def reolink_host() -> Generator[MagicMock]: +def reolink_host(reolink_host_class: MagicMock) -> Generator[MagicMock]: """Mock reolink Host class.""" - with patch( - "homeassistant.components.reolink.host.Host", autospec=False - ) as host_mock_class: - host_mock = host_mock_class.return_value - host_mock.baichuan = MagicMock() - _init_host_mock(host_mock) - yield host_mock + return reolink_host_class.return_value @pytest.fixture @@ -246,29 +232,6 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture -def test_chime(reolink_connect: MagicMock) -> None: - """Mock a reolink chime.""" - TEST_CHIME = Chime( - host=reolink_connect, - dev_id=12345678, - channel=0, - ) - TEST_CHIME.name = "Test chime" - TEST_CHIME.volume = 3 - TEST_CHIME.connect_state = 2 - TEST_CHIME.led_state = True - TEST_CHIME.event_info = { - "md": {"switch": 0, "musicId": 0}, - "people": {"switch": 0, "musicId": 1}, - "visitor": {"switch": 1, "musicId": 2}, - } - - reolink_connect.chime_list = [TEST_CHIME] - reolink_connect.chime.return_value = TEST_CHIME - return TEST_CHIME - - @pytest.fixture def reolink_chime(reolink_host: MagicMock) -> None: """Mock a reolink chime.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4b116929ac8..0a837a97b20 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -58,7 +58,7 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_host") async def test_config_flow_manual_success( @@ -101,11 +101,11 @@ async def test_config_flow_manual_success( async def test_config_flow_privacy_success( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_host_data.side_effect = LoginPrivacyModeError("Test error") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,13 +128,13 @@ async def test_config_flow_privacy_success( assert result["step_id"] == "privacy" assert result["errors"] is None - assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 - reolink_connect.get_host_data.reset_mock(side_effect=True) + assert reolink_host.baichuan.set_privacy_mode.call_count == 0 + reolink_host.get_host_data.reset_mock(side_effect=True) with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + assert reolink_host.baichuan.set_privacy_mode.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_NVR_NAME @@ -153,14 +153,12 @@ async def test_config_flow_privacy_success( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_config_flow_baichuan_only( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user for baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -196,11 +194,9 @@ async def test_config_flow_baichuan_only( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan_only = False - async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -211,10 +207,10 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {} - reolink_connect.is_admin = False - reolink_connect.user_level = "guest" - reolink_connect.unsubscribe.side_effect = ReolinkError("Test error") - reolink_connect.logout.side_effect = ReolinkError("Test error") + reolink_host.is_admin = False + reolink_host.user_level = "guest" + reolink_host.unsubscribe.side_effect = ReolinkError("Test error") + reolink_host.logout.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -228,9 +224,9 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - reolink_connect.is_admin = True - reolink_connect.user_level = "admin" - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.is_admin = True + reolink_host.user_level = "admin" + reolink_host.get_host_data.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -244,7 +240,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} - reolink_connect.get_host_data.side_effect = ReolinkWebhookException("Test error") + reolink_host.get_host_data.side_effect = ReolinkWebhookException("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -258,7 +254,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "webhook_exception"} - reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + reolink_host.get_host_data.side_effect = json.JSONDecodeError( "test_error", "test", 1 ) result = await hass.config_entries.flow.async_configure( @@ -274,7 +270,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_host_data.side_effect = CredentialsInvalidError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -288,7 +284,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} - reolink_connect.get_host_data.side_effect = LoginFirmwareError("Test error") + reolink_host.get_host_data.side_effect = LoginFirmwareError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -302,7 +298,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "update_needed"} - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -316,8 +312,8 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "password_incompatible"} - reolink_connect.valid_password.return_value = True - reolink_connect.get_host_data.side_effect = ApiError("Test error") + reolink_host.valid_password.return_value = True + reolink_host.get_host_data.side_effect = ApiError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -331,7 +327,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} - reolink_connect.get_host_data.reset_mock(side_effect=True) + reolink_host.get_host_data.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -360,9 +356,6 @@ async def test_config_flow_errors( CONF_PROTOCOL: DEFAULT_PROTOCOL, } - reolink_connect.unsubscribe.reset_mock(side_effect=True) - reolink_connect.logout.reset_mock(side_effect=True) - async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" @@ -450,7 +443,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: async def test_reauth_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( @@ -475,7 +468,7 @@ async def test_reauth_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reauth_flow(hass) @@ -497,8 +490,6 @@ async def test_reauth_abort_unique_id_mismatch( assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - reolink_connect.mac_address = TEST_MAC - async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" @@ -544,8 +535,8 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No async def test_dhcp_ip_update_aborted_if_wrong_mac( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery does not update the IP if the mac address does not match.""" config_entry = MockConfigEntry( @@ -572,7 +563,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -583,7 +574,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( macaddress=DHCP_FORMATTED_MAC, ) - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -602,9 +593,9 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in [TEST_HOST, TEST_HOST2] get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -616,10 +607,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( # Check that IP was not updated assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - reolink_connect.mac_address = TEST_MAC - @pytest.mark.parametrize( ("attr", "value", "expected", "host_call_list"), @@ -641,8 +628,8 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async def test_dhcp_ip_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: str, @@ -673,7 +660,7 @@ async def test_dhcp_ip_update( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -685,8 +672,7 @@ async def test_dhcp_ip_update( ) if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -705,9 +691,9 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in host_call_list get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -718,17 +704,12 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - if attr is not None: - setattr(reolink_connect, attr, original) - async def test_dhcp_ip_update_ingnored_if_still_connected( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery is ignored when the camera is still properly connected to HA.""" config_entry = MockConfigEntry( @@ -776,9 +757,9 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] == TEST_HOST get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -789,9 +770,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" @@ -840,7 +818,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non async def test_reconfig_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reconfiguration flow aborts if the unique id does not match.""" config_entry = MockConfigEntry( @@ -865,7 +843,7 @@ async def test_reconfig_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reconfigure_flow(hass) @@ -887,5 +865,3 @@ async def test_reconfig_abort_unique_id_mismatch( assert config_entry.data[CONF_HOST] == TEST_HOST assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - - reolink_connect.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 32bc5e4435e..fb0f98a6e31 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -29,7 +29,7 @@ async def test_floodlight_mode_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select entity with floodlight_mode.""" @@ -47,9 +47,9 @@ async def test_floodlight_mode_select( {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_once() + reolink_host.set_whiteled.assert_called_once() - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + reolink_host.set_whiteled.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -58,7 +58,7 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -67,24 +67,22 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.whiteled_mode.return_value = -99 # invalid value + reolink_host.whiteled_mode.return_value = -99 # invalid value freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_play_quick_reply_message( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select play_quick_reply_message entity.""" - reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"} + reolink_host.quick_reply_dict.return_value = {0: "off", 1: "test message"} with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -99,16 +97,14 @@ async def test_play_quick_reply_message( {ATTR_ENTITY_ID: entity_id, "option": "test message"}, blocking=True, ) - reolink_connect.play_quick_reply.assert_called_once() - - reolink_connect.quick_reply_dict = MagicMock() + reolink_host.play_quick_reply.assert_called_once() async def test_host_scene_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host select entity with scene mode.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): @@ -125,9 +121,9 @@ async def test_host_scene_select( {ATTR_ENTITY_ID: entity_id, "option": "home"}, blocking=True, ) - reolink_connect.baichuan.set_scene.assert_called_once() + reolink_host.baichuan.set_scene.assert_called_once() - reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + reolink_host.baichuan.set_scene.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -136,7 +132,7 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + reolink_host.baichuan.set_scene.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -145,23 +141,20 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.active_scene = "Invalid value" + reolink_host.baichuan.active_scene = "Invalid value" freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) - reolink_connect.baichuan.active_scene = "off" - async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime select entity.""" @@ -175,16 +168,16 @@ async def test_chime_select( assert hass.states.get(entity_id).state == "pianokey" # Test selecting chime ringtone option - test_chime.set_tone = AsyncMock() + reolink_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - test_chime.set_tone.assert_called_once() + reolink_chime.set_tone.assert_called_once() - test_chime.set_tone.side_effect = ReolinkError("Test error") + reolink_chime.set_tone.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -193,7 +186,7 @@ async def test_chime_select( blocking=True, ) - test_chime.set_tone.side_effect = InvalidParameterError("Test error") + reolink_chime.set_tone.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -203,11 +196,9 @@ async def test_chime_select( ) # Test unavailable - test_chime.event_info = {} + reolink_chime.event_info = {} freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - - test_chime.set_tone.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d48362516b8..d12b229e932 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -30,11 +30,11 @@ TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" async def test_no_update( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when no update available.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_name.return_value = TEST_CAM_NAME with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -49,12 +49,12 @@ async def test_no_update( async def test_update_str( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when update available with string from API.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.firmware_update_available.return_value = "New firmware available" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.firmware_update_available.return_value = "New firmware available" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -69,21 +69,21 @@ async def test_update_str( async def test_update_firm( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.sw_upload_progress.return_value = 100 - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.sw_upload_progress.return_value = 100 + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -117,9 +117,9 @@ async def test_update_firm( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.update_firmware.assert_called() + reolink_host.update_firmware.assert_called() - reolink_connect.sw_upload_progress.return_value = 50 + reolink_host.sw_upload_progress.return_value = 50 freezer.tick(POLL_PROGRESS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -127,7 +127,7 @@ async def test_update_firm( assert hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] == 50 - reolink_connect.sw_upload_progress.return_value = 100 + reolink_host.sw_upload_progress.return_value = 100 freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -135,7 +135,7 @@ async def test_update_firm( assert not hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] is None - reolink_connect.update_firmware.side_effect = ReolinkError("Test error") + reolink_host.update_firmware.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( UPDATE_DOMAIN, @@ -144,7 +144,7 @@ async def test_update_firm( blocking=True, ) - reolink_connect.update_firmware.side_effect = ApiError( + reolink_host.update_firmware.side_effect = ApiError( "Test error", translation_key="firmware_rate_limit" ) with pytest.raises(HomeAssistantError): @@ -156,34 +156,32 @@ async def test_update_firm( ) # test _async_update_future - reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" - reolink_connect.firmware_update_available.return_value = False + reolink_host.camera_sw_version.return_value = "v3.3.0.226_23031644" + reolink_host.firmware_update_available.return_value = False freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF - reolink_connect.update_firmware.side_effect = None - @pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) async def test_update_firm_keeps_available( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,7 +194,7 @@ async def test_update_firm_keeps_available( async def mock_update_firmware(*args, **kwargs) -> None: await asyncio.sleep(0.000005) - reolink_connect.update_firmware = mock_update_firmware + reolink_host.update_firmware = mock_update_firmware # test install with patch("homeassistant.components.reolink.update.POLL_PROGRESS", 0.000001): @@ -207,11 +205,9 @@ async def test_update_firm_keeps_available( blocking=True, ) - reolink_connect.session_active = False + reolink_host.session_active = False async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() # still available assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.session_active = True From 7f9be420d2180cd44a5e08bec26bdf37e70c1239 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 28 Jul 2025 20:54:54 +0200 Subject: [PATCH 0713/1118] Add details to Husqvarna Automower restricted reason sensor (#147678) Co-authored-by: Norbert Rittel --- .../components/husqvarna_automower/sensor.py | 29 ++++++++++- .../husqvarna_automower/strings.json | 26 +++++++--- .../snapshots/test_sensor.ambr | 48 +++++++++++++++++++ .../husqvarna_automower/test_sensor.py | 43 ++++++++++++++++- 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0ff72271cb9..7f2921f17fa 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -8,6 +8,7 @@ from operator import attrgetter from typing import TYPE_CHECKING, Any from aioautomower.model import ( + ExternalReasons, InactiveReasons, MowerAttributes, MowerModes, @@ -190,11 +191,37 @@ RESTRICTED_REASONS: list = [ RestrictedReasons.PARK_OVERRIDE, RestrictedReasons.SENSOR, RestrictedReasons.WEEK_SCHEDULE, + ExternalReasons.AMAZON_ALEXA, + ExternalReasons.DEVELOPER_PORTAL, + ExternalReasons.GARDENA_SMART_SYSTEM, + ExternalReasons.GOOGLE_ASSISTANT, + ExternalReasons.HOME_ASSISTANT, + ExternalReasons.IFTTT, + ExternalReasons.IFTTT_APPLETS, + ExternalReasons.IFTTT_CALENDAR_CONNECTION, + ExternalReasons.SMART_ROUTINE, + ExternalReasons.SMART_ROUTINE_FROST_GUARD, + ExternalReasons.SMART_ROUTINE_RAIN_GUARD, + ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION, ] STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active" +@callback +def _get_restricted_reason(data: MowerAttributes) -> str: + """Return the restricted reason. + + If there is an external reason, return that instead, if it's available. + """ + if ( + data.planner.restricted_reason == RestrictedReasons.EXTERNAL + and data.planner.external_reason is not None + ): + return data.planner.external_reason + return data.planner.restricted_reason + + @callback def _get_work_area_names(data: MowerAttributes) -> list[str]: """Return a list with all work area names.""" @@ -400,7 +427,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( translation_key="restricted_reason", device_class=SensorDeviceClass.ENUM, option_fn=lambda data: RESTRICTED_REASONS, - value_fn=attrgetter("planner.restricted_reason"), + value_fn=_get_restricted_reason, ), AutomowerSensorEntityDescription( key="inactive_reason", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 62843d67ae2..226c9ee17f0 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -242,16 +242,28 @@ "restricted_reason": { "name": "Restricted reason", "state": { - "none": "No restrictions", - "week_schedule": "Week schedule", - "park_override": "Park override", - "sensor": "Weather timer", + "all_work_areas_completed": "All work areas completed", + "amazon_alexa": "Amazon Alexa", "daily_limit": "Daily limit", + "developer_portal": "Developer Portal", + "external": "External", "fota": "Firmware Over-the-Air update running", "frost": "Frost", - "all_work_areas_completed": "All work areas completed", - "external": "External", - "not_applicable": "Not applicable" + "gardena_smart_system": "Gardena Smart System", + "google_assistant": "Google Assistant", + "home_assistant": "Home Assistant", + "ifttt_applets": "IFTTT applets", + "ifttt_calendar_connection": "IFTTT calendar connection", + "ifttt": "IFTTT", + "none": "No restrictions", + "not_applicable": "Not applicable", + "park_override": "Park override", + "sensor": "Weather timer", + "smart_routine_frost_guard": "Frost guard", + "smart_routine_rain_guard": "Rain guard", + "smart_routine_wildlife_protection": "Wildlife protection", + "smart_routine": "Generic smart routine", + "week_schedule": "Week schedule" } }, "total_charging_time": { diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 0fe46c24254..3aa3504cc26 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -978,6 +978,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -1025,6 +1037,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , @@ -1953,6 +1977,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -2000,6 +2036,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index d756b1b2ffa..204fba872c4 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -4,7 +4,13 @@ import datetime from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.model import MowerAttributes, MowerModes, MowerStates +from aioautomower.model import ( + ExternalReasons, + MowerAttributes, + MowerModes, + MowerStates, + RestrictedReasons, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -123,6 +129,41 @@ async def test_work_area_sensor( assert state.state == "no_work_area_active" +async def test_restricted_reason_sensor( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test the work area sensor.""" + sensor = "sensor.test_mower_1_restricted_reason" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(sensor) + assert state is not None + assert state.state == RestrictedReasons.WEEK_SCHEDULE + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[TEST_MOWER_ID].planner.external_reason = None + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == RestrictedReasons.EXTERNAL + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[ + TEST_MOWER_ID + ].planner.external_reason = ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), From cf05f1046d9c1617eff533bcb65c5af3f4bce6e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 28 Jul 2025 22:19:51 +0200 Subject: [PATCH 0714/1118] Add action to retrieve list of programs on miele appliance (#149307) --- homeassistant/components/miele/icons.json | 3 + homeassistant/components/miele/services.py | 108 ++++++++++++++-- homeassistant/components/miele/services.yaml | 8 ++ homeassistant/components/miele/strings.json | 15 ++- tests/components/miele/conftest.py | 6 +- tests/components/miele/fixtures/programs.json | 34 +++++ .../fixtures/programs_washing_machine.json | 117 ------------------ .../miele/snapshots/test_services.ambr | 48 +++++++ tests/components/miele/test_services.py | 54 +++++++- 9 files changed, 262 insertions(+), 131 deletions(-) create mode 100644 tests/components/miele/fixtures/programs.json delete mode 100644 tests/components/miele/fixtures/programs_washing_machine.json create mode 100644 tests/components/miele/snapshots/test_services.ambr diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 1b757a9e113..4a0eac7da85 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -105,6 +105,9 @@ } }, "services": { + "get_programs": { + "service": "mdi:stack-overflow" + }, "set_program": { "service": "mdi:arrow-right-circle-outline" } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 70ea20ccc4a..6d4dc77dd36 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -7,7 +7,12 @@ import aiohttp import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.service import async_extract_config_entry_ids @@ -27,6 +32,13 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( }, ) +SERVICE_GET_PROGRAMS = "get_programs" +SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, +) + _LOGGER = logging.getLogger(__name__) @@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: return target_entries[0] -async def set_program(call: ServiceCall) -> None: - """Set a program on a Miele appliance.""" +async def _get_serial_number(call: ServiceCall) -> str: + """Extract the serial number from the device identifier.""" - _LOGGER.debug("Set program call: %s", call) - config_entry = await _extract_config_entry(call) device_reg = dr.async_get(call.hass) - api = config_entry.runtime_data.api device = call.data[ATTR_DEVICE_ID] device_entry = device_reg.async_get(device) - - data = {"programId": call.data[ATTR_PROGRAM_ID]} serial_number = next( ( identifier[1] @@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None: translation_domain=DOMAIN, translation_key="invalid_target", ) + return serial_number + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} try: await api.set_program(serial_number, data) except aiohttp.ClientResponseError as ex: @@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None: ) from ex +async def get_programs(call: ServiceCall) -> ServiceResponse: + """Get available programs from appliance.""" + + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + serial_number = await _get_serial_number(call) + + try: + programs = await api.get_programs(serial_number) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_programs_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + return { + "programs": [ + { + "program_id": item["programId"], + "program": item["program"], + "parameters": ( + { + "temperature": ( + { + "min": item["parameters"]["temperature"]["min"], + "max": item["parameters"]["temperature"]["max"], + "step": item["parameters"]["temperature"]["step"], + "mandatory": item["parameters"]["temperature"][ + "mandatory" + ], + } + if "temperature" in item["parameters"] + else {} + ), + "duration": ( + { + "min": { + "hours": item["parameters"]["duration"]["min"][0], + "minutes": item["parameters"]["duration"]["min"][1], + }, + "max": { + "hours": item["parameters"]["duration"]["max"][0], + "minutes": item["parameters"]["duration"]["max"][1], + }, + "mandatory": item["parameters"]["duration"][ + "mandatory" + ], + } + if "duration" in item["parameters"] + else {} + ), + } + if item["parameters"] + else {} + ), + } + for item in programs + ], + } + + async def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" hass.services.async_register( DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PROGRAMS, + get_programs, + SERVICE_GET_PROGRAMS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml index 486fdf7307b..6866e997c45 100644 --- a/homeassistant/components/miele/services.yaml +++ b/homeassistant/components/miele/services.yaml @@ -1,5 +1,13 @@ # Services descriptions for Miele integration +get_programs: + fields: + device_id: + selector: + device: + integration: miele + required: true + set_program: fields: device_id: diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 2ae412ed95e..5b5cac16b53 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1062,6 +1062,9 @@ "invalid_target": { "message": "Invalid device targeted." }, + "get_programs_error": { + "message": "'Get programs' action failed {status} / {message}." + }, "set_program_error": { "message": "'Set program' action failed {status} / {message}." }, @@ -1070,12 +1073,22 @@ } }, "services": { + "get_programs": { + "name": "Get programs", + "description": "Returns a list of available programs.", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + } + } + }, "set_program": { "name": "Set program", "description": "Sets and starts a program on the appliance.", "fields": { "device_id": { - "description": "The device to set the program on.", + "description": "The target device for this action.", "name": "Device" }, "program_id": { diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 7b3c3f35f7e..d91485ffc59 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET from tests.common import ( MockConfigEntry, - async_load_fixture, async_load_json_object_fixture, + load_json_value_fixture, ) @@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct @pytest.fixture(scope="package") def load_programs_file() -> str: """Fixture for loading programs file.""" - return "programs_washing_machine.json" + return "programs.json" @pytest.fixture async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: """Fixture for available programs.""" - return await async_load_fixture(hass, load_programs_file, DOMAIN) + return load_json_value_fixture(load_programs_file, DOMAIN) @pytest.fixture diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json new file mode 100644 index 00000000000..06eddc5fedc --- /dev/null +++ b/tests/components/miele/fixtures/programs.json @@ -0,0 +1,34 @@ +[ + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim", + "parameters": {} + }, + { + "programId": 13, + "program": "Fan plus", + "parameters": { + "temperature": { + "min": 30, + "max": 250, + "step": 5, + "mandatory": false + }, + "duration": { + "min": [0, 1], + "max": [12, 0], + "mandatory": true + } + } + } +] diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json deleted file mode 100644 index a3c16ece8e6..00000000000 --- a/tests/components/miele/fixtures/programs_washing_machine.json +++ /dev/null @@ -1,117 +0,0 @@ -[ - { - "programId": 146, - "program": "QuickPowerWash", - "parameters": {} - }, - { - "programId": 123, - "program": "Dark garments / Denim", - "parameters": {} - }, - { - "programId": 190, - "program": "ECO 40-60 ", - "parameters": {} - }, - { - "programId": 27, - "program": "Proofing", - "parameters": {} - }, - { - "programId": 23, - "program": "Shirts", - "parameters": {} - }, - { - "programId": 9, - "program": "Silks ", - "parameters": {} - }, - { - "programId": 8, - "program": "Woollens ", - "parameters": {} - }, - { - "programId": 4, - "program": "Delicates", - "parameters": {} - }, - { - "programId": 3, - "program": "Minimum iron", - "parameters": {} - }, - { - "programId": 1, - "program": "Cottons", - "parameters": {} - }, - { - "programId": 69, - "program": "Cottons hygiene", - "parameters": {} - }, - { - "programId": 37, - "program": "Outerwear", - "parameters": {} - }, - { - "programId": 122, - "program": "Express 20", - "parameters": {} - }, - { - "programId": 29, - "program": "Sportswear", - "parameters": {} - }, - { - "programId": 31, - "program": "Automatic plus", - "parameters": {} - }, - { - "programId": 39, - "program": "Pillows", - "parameters": {} - }, - { - "programId": 22, - "program": "Curtains", - "parameters": {} - }, - { - "programId": 129, - "program": "Down filled items", - "parameters": {} - }, - { - "programId": 53, - "program": "First wash", - "parameters": {} - }, - { - "programId": 95, - "program": "Down duvets", - "parameters": {} - }, - { - "programId": 52, - "program": "Separate rinse / Starch", - "parameters": {} - }, - { - "programId": 21, - "program": "Drain / Spin", - "parameters": {} - }, - { - "programId": 91, - "program": "Clean machine", - "parameters": {} - } -] diff --git a/tests/components/miele/snapshots/test_services.ambr b/tests/components/miele/snapshots/test_services.ambr new file mode 100644 index 00000000000..3095ec9b6fb --- /dev/null +++ b/tests/components/miele/snapshots/test_services.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_services_with_response + dict({ + 'programs': list([ + dict({ + 'parameters': dict({ + }), + 'program': 'Cottons', + 'program_id': 1, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'QuickPowerWash', + 'program_id': 146, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'Dark garments / Denim', + 'program_id': 123, + }), + dict({ + 'parameters': dict({ + 'duration': dict({ + 'mandatory': True, + 'max': dict({ + 'hours': 12, + 'minutes': 0, + }), + 'min': dict({ + 'hours': 0, + 'minutes': 1, + }), + }), + 'temperature': dict({ + 'mandatory': False, + 'max': 250, + 'min': 30, + 'step': 5, + }), + }), + 'program': 'Fan plus', + 'program_id': 13, + }), + ]), + }) +# --- diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py index 8b33c17d69f..2bf0e2deb9c 100644 --- a/tests/components/miele/test_services.py +++ b/tests/components/miele/test_services.py @@ -4,10 +4,15 @@ from unittest.mock import MagicMock from aiohttp import ClientResponseError import pytest +from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid from homeassistant.components.miele.const import DOMAIN -from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.components.miele.services import ( + ATTR_PROGRAM_ID, + SERVICE_GET_PROGRAMS, + SERVICE_SET_PROGRAM, +) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -44,6 +49,28 @@ async def test_services( ) +async def test_services_with_response( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the custom services that returns a response are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + { + ATTR_DEVICE_ID: device.id, + }, + blocking=True, + return_response=True, + ) + + async def test_service_api_errors( hass: HomeAssistant, device_registry: DeviceRegistry, @@ -60,7 +87,7 @@ async def test_service_api_errors( await hass.services.async_call( DOMAIN, SERVICE_SET_PROGRAM, - {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1}, blocking=True, ) mock_miele_client.set_program.assert_called_once_with( @@ -68,6 +95,29 @@ async def test_service_api_errors( ) +async def test_get_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.get_programs.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Get programs' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + {ATTR_DEVICE_ID: device.id}, + blocking=True, + return_response=True, + ) + mock_miele_client.get_programs.assert_called_once() + + async def test_service_validation_errors( hass: HomeAssistant, device_registry: DeviceRegistry, From 596f6cd216ccf2df6bff0109ec0a20630684ce29 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:21:04 +0200 Subject: [PATCH 0715/1118] Add people and tags collections to Immich media source (#149340) --- .../components/immich/media_source.py | 152 ++++-- tests/components/immich/conftest.py | 104 +++- tests/components/immich/const.py | 129 +++++ tests/components/immich/test_media_source.py | 477 ++++++++++++------ 4 files changed, 665 insertions(+), 197 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index caf8264895b..008a807c0d2 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -5,6 +5,7 @@ from __future__ import annotations from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse +from aioimmich.assets.models import ImmichAsset from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView @@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource): self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" + + # -------------------------------------------------------- + # root level, render immich instances + # -------------------------------------------------------- if not item.identifier: LOGGER.debug("Render all Immich instances") return [ @@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource): ) for entry in entries ] + + # -------------------------------------------------------- + # 1st level, render collections overview + # -------------------------------------------------------- identifier = ImmichMediaSourceIdentifier(item.identifier) entry: ImmichConfigEntry | None = ( self.hass.config_entries.async_entry_for_domain_unique_id( @@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums", + identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title="albums", + title=collection, can_play=False, can_expand=True, ) + for collection in ("albums", "people", "tags") ] + # -------------------------------------------------------- + # 2nd level, render collection + # -------------------------------------------------------- if identifier.collection_id is None: - LOGGER.debug("Render all albums for %s", entry.title) + if identifier.collection == "albums": + LOGGER.debug("Render all albums for %s", entry.title) + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums|{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.album_name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + ) + for album in albums + ] + + if identifier.collection == "tags": + LOGGER.debug("Render all tags for %s", entry.title) + try: + tags = await immich_api.tags.async_get_all_tags() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|tags|{tag.tag_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=tag.name, + can_play=False, + can_expand=True, + ) + for tag in tags + ] + + if identifier.collection == "people": + LOGGER.debug("Render all people for %s", entry.title) + try: + people = await immich_api.people.async_get_all_people() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|people|{person.person_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=person.name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg", + ) + for person in people + ] + + # -------------------------------------------------------- + # final level, render assets + # -------------------------------------------------------- + assert identifier.collection_id is not None + assets: list[ImmichAsset] = [] + if identifier.collection == "albums": + LOGGER.debug( + "Render all assets of album %s for %s", + identifier.collection_id, + entry.title, + ) try: - albums = await immich_api.albums.async_get_all_albums() + album_info = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) + assets = album_info.assets except ImmichError: return [] - return [ - BrowseMediaSource( - domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums|{album.album_id}", - media_class=MediaClass.DIRECTORY, - media_content_type=MediaClass.IMAGE, - title=album.album_name, - can_play=False, - can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", - ) - for album in albums - ] - - LOGGER.debug( - "Render all assets of album %s for %s", - identifier.collection_id, - entry.title, - ) - try: - album_info = await immich_api.albums.async_get_album_info( - identifier.collection_id + elif identifier.collection == "tags": + LOGGER.debug( + "Render all assets with tag %s", + identifier.collection_id, ) - except ImmichError: - return [] + try: + assets = await immich_api.search.async_get_all_by_tag_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] + + elif identifier.collection == "people": + LOGGER.debug( + "Render all assets for person %s", + identifier.collection_id, + ) + try: + assets = await immich_api.search.async_get_all_by_person_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] ret: list[BrowseMediaSource] = [] - for asset in album_info.assets: + for asset in assets: if not (mime_type := asset.original_mime_type) or not mime_type.startswith( ("image/", "video/") ): @@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}|albums|" + f"{identifier.unique_id}|" + f"{identifier.collection}|" f"{identifier.collection_id}|" f"{asset.asset_id}|" f"{asset.original_file_name}|" @@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView): # web response for images try: - image = await immich_api.assets.async_view_asset(asset_id, size) + if size == "person": + image = await immich_api.people.async_get_person_thumbnail(asset_id) + else: + image = await immich_api.assets.async_view_asset(asset_id, size) except ImmichError as exc: raise HTTPNotFound from exc return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 48e36e70386..adcbf14d97b 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -4,15 +4,25 @@ from collections.abc import AsyncGenerator, Generator from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich import ( + ImmichAlbums, + ImmichAssests, + ImmichPeople, + ImmichSearch, + ImmichServer, + ImmichTags, + ImmichUsers, +) from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse from aioimmich.assets.models import ImmichAssetUploadResponse +from aioimmich.people.models import ImmichPerson from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, ImmichServerVersionCheck, ) +from aioimmich.tags.models import ImmichTag from aioimmich.users.models import ImmichUserObject import pytest @@ -29,7 +39,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked -from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS +from .const import ( + MOCK_ALBUM_WITH_ASSETS, + MOCK_ALBUM_WITHOUT_ASSETS, + MOCK_PEOPLE_ASSETS, + MOCK_TAGS_ASSETS, +) from tests.common import MockConfigEntry @@ -87,6 +102,58 @@ def mock_immich_assets() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_people() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichPeople) + mock.async_get_all_people.return_value = [ + ImmichPerson.from_dict( + { + "id": "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "name": "Me", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/61/76/6176838a-ac5a-4d1f-9a35-91c591d962d8.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-11T11:07:41.651Z", + } + ), + ImmichPerson.from_dict( + { + "id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f", + "name": "I", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/3e/66/3e66aa4a-a4a8-41a4-86fe-2ae5e490078f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-19T22:10:21.953Z", + } + ), + ImmichPerson.from_dict( + { + "id": "a3c83297-684a-4576-82dc-b07432e8a18f", + "name": "Myself", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/a3/c8/a3c83297-684a-4576-82dc-b07432e8a18f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-12T21:07:04.044Z", + } + ), + ] + mock.async_get_person_thumbnail.return_value = b"yyyy" + return mock + + +@pytest.fixture +def mock_immich_search() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichSearch) + mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS + mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS + return mock + + @pytest.fixture def mock_immich_server() -> AsyncMock: """Mock the Immich server.""" @@ -153,6 +220,33 @@ def mock_immich_server() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_tags() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichTags) + mock.async_get_all_tags.return_value = [ + ImmichTag.from_dict( + { + "id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + "name": "Halloween", + "value": "Halloween", + "createdAt": "2025-05-12T20:00:45.220Z", + "updatedAt": "2025-05-12T20:00:47.224Z", + }, + ), + ImmichTag.from_dict( + { + "id": "69bd487f-dc1e-4420-94c6-656f0515773d", + "name": "Holidays", + "value": "Holidays", + "createdAt": "2025-05-12T20:00:49.967Z", + "updatedAt": "2025-05-12T20:00:55.575Z", + }, + ), + ] + return mock + + @pytest.fixture def mock_immich_user() -> AsyncMock: """Mock the Immich server.""" @@ -185,7 +279,10 @@ def mock_immich_user() -> AsyncMock: async def mock_immich( mock_immich_albums: AsyncMock, mock_immich_assets: AsyncMock, + mock_immich_people: AsyncMock, + mock_immich_search: AsyncMock, mock_immich_server: AsyncMock, + mock_immich_tags: AsyncMock, mock_immich_user: AsyncMock, ) -> AsyncGenerator[AsyncMock]: """Mock the Immich API.""" @@ -196,7 +293,10 @@ async def mock_immich( client = mock_immich.return_value client.albums = mock_immich_albums client.assets = mock_immich_assets + client.people = mock_immich_people + client.search = mock_immich_search client.server = mock_immich_server + client.tags = mock_immich_tags client.users = mock_immich_user yield client diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index 97721bc7dbc..af718c4b754 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,6 +1,7 @@ """Constants for the Immich integration tests.""" from aioimmich.albums.models import ImmichAlbum +from aioimmich.assets.models import ImmichAsset from homeassistant.const import ( CONF_API_KEY, @@ -113,3 +114,131 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict( ], } ) + +MOCK_PEOPLE_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "deviceAssetId": "1000092019", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/8e/a3/8ea31ee8-49c3-4be9-aa9d-b8ef26ba0abe.jpg", + "originalFileName": "20250714_201122.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILGeMlPaJaMWIeagJcJSA==", + "fileCreatedAt": "2025-07-14T18:11:22.648Z", + "fileModifiedAt": "2025-07-14T18:11:25.000Z", + "localDateTime": "2025-07-14T20:11:22.648Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "GcBJkDFoXx9d/wyl1xH89R4/NBQ=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + } + ), + ImmichAsset.from_dict( + { + "id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "deviceAssetId": "1000092018", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f5/b4/f5b4b200-47dd-45e8-98a4-4128df3f9189.jpg", + "originalFileName": "20250714_201121.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILHeMlPeJaMSJmKgJcIWQ==", + "fileCreatedAt": "2025-07-14T18:11:21.582Z", + "fileModifiedAt": "2025-07-14T18:11:24.000Z", + "localDateTime": "2025-07-14T20:11:21.582Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "X6kMpPulu/HJQnKmTqCoQYl3Sjc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] + +MOCK_TAGS_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "deviceAssetId": "2132393", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/07/d0/07d04d86-7188-4335-95ca-9bd9fd2b399d.JPG", + "originalFileName": "20110306_025024.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "WCgSFYRXaYdQiYineIiHd4SghQUY", + "fileCreatedAt": "2011-03-06T01:50:24.000Z", + "fileModifiedAt": "2011-03-06T01:50:24.000Z", + "localDateTime": "2011-03-06T02:50:24.000Z", + "updatedAt": "2025-07-26T10:16:39.477Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "eNwN0AN2hEYZJJkonl7ylGzJzko=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), + ImmichAsset.from_dict( + { + "id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "deviceAssetId": "2142137", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/4a/f4/4af42484-86f8-47a0-958a-f32da89ee03a.JPG", + "originalFileName": "20110306_024053.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "4AcKFYZPZnhSmGl5daaYeG859ytT", + "fileCreatedAt": "2011-03-06T01:40:53.000Z", + "fileModifiedAt": "2011-03-06T01:40:52.000Z", + "localDateTime": "2011-03-06T02:40:53.000Z", + "updatedAt": "2025-07-26T10:16:39.474Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "VtokCjIwKqnHBFzH3kHakIJiq5I=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 5b396a780cc..6bd23b272ed 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -26,7 +26,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked from . import setup_integration -from .const import MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -143,7 +142,8 @@ async def test_browse_media_get_root( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 3 + media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" @@ -151,174 +151,289 @@ async def test_browse_media_get_root( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) - -async def test_browse_media_get_albums( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 1 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.title == "My Album" - assert media_file.media_content_id == ( - "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" - ) - - -async def test_browse_media_get_albums_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media with unknown album.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - # exception in get_albums() - mock_immich.albums.async_get_all_albums.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - - source = await async_get_media_source(hass) - - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items_error( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - # unknown album - mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - # exception in async_get_album_info() - mock_immich.albums.async_get_album_info.side_effect = ImmichError( - { - "message": "Not found or no album.read access", - "error": "Bad Request", - "statusCode": 400, - "correlationId": "e0hlizyl", - } - ) - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - -async def test_browse_media_get_album_items( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning albums.""" - assert await async_setup_component(hass, "media_source", {}) - - with patch("homeassistant.components.immich.PLATFORMS", []): - await setup_integration(hass, mock_config_entry) - - source = await async_get_media_source(hass) - - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert len(result.children) == 2 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg" - ) - assert media_file.title == "filename.jpg" - assert media_file.media_class == MediaClass.IMAGE - assert media_file.media_content_type == "image/jpeg" - assert media_file.can_play is False - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg" - ) - media_file = result.children[1] assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" + assert media_file.title == "people" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people" ) - assert media_file.title == "filename.mp4" - assert media_file.media_class == MediaClass.VIDEO - assert media_file.media_content_type == "video/mp4" - assert media_file.can_play is True - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" + + media_file = result.children[2] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "tags" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags" ) +@pytest.mark.parametrize( + ("collection", "children"), + [ + ( + "albums", + [{"title": "My Album", "asset_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"}], + ), + ( + "people", + [ + {"title": "Me", "asset_id": "6176838a-ac5a-4d1f-9a35-91c591d962d8"}, + {"title": "I", "asset_id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f"}, + {"title": "Myself", "asset_id": "a3c83297-684a-4576-82dc-b07432e8a18f"}, + ], + ), + ( + "tags", + [ + { + "title": "Halloween", + "asset_id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + }, + { + "title": "Holidays", + "asset_id": "69bd487f-dc1e-4420-94c6-656f0515773d", + }, + ], + ), + ], +) +async def test_browse_media_collections( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + children: list[dict], +) -> None: + """Test browse through collections.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == child["title"] + assert media_file.media_content_id == ( + "media-source://immich/" + f"{mock_config_entry.unique_id}|{collection}|" + f"{child['asset_id']}" + ) + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_all_albums")), + ("people", ("people", "async_get_all_people")), + ("tags", ("tags", "async_get_all_tags")), + ], +) +async def test_browse_media_collections_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media with unknown collection.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_album_info")), + ("people", ("search", "async_get_all_by_person_ids")), + ("tags", ("search", "async_get_all_by_tag_ids")), + ], +) +async def test_browse_media_collection_items_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.parametrize( + ("collection", "collection_id", "children"), + [ + ( + "albums", + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + [ + { + "original_file_name": "filename.jpg", + "asset_id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "filename.mp4", + "asset_id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "media_class": MediaClass.VIDEO, + "media_content_type": "video/mp4", + "thumb_mime_type": "image/jpeg", + "can_play": True, + }, + ], + ), + ( + "people", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20250714_201122.jpg", + "asset_id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20250714_201121.jpg", + "asset_id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ( + "tags", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20110306_025024.jpg", + "asset_id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20110306_024053.jpg", + "asset_id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ], +) +async def test_browse_media_collection_get_items( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, + collection: str, + collection_id: str, + children: list[dict], +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + f"{mock_config_entry.unique_id}|{collection}|{collection_id}", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == len(children) + + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + f"{mock_config_entry.unique_id}|{collection}|{collection_id}|" + f"{child['asset_id']}|{child['original_file_name']}|{child['media_content_type']}" + ) + assert media_file.title == child["original_file_name"] + assert media_file.media_class == child["media_class"] + assert media_file.media_content_type == child["media_content_type"] + assert media_file.can_play is child["can_play"] + assert not media_file.can_expand + assert media_file.thumbnail == ( + f"/immich/{mock_config_entry.unique_id}/" + f"{child['asset_id']}/thumbnail/{child['thumb_mime_type']}" + ) + + async def test_media_view( hass: HomeAssistant, tmp_path: Path, @@ -362,6 +477,22 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) + # exception in async_get_person_thumbnail() + mock_immich.people.async_get_person_thumbnail.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + # exception in async_play_video_stream() mock_immich.assets.async_play_video_stream.side_effect = ImmichError( { @@ -396,6 +527,24 @@ async def test_media_view( ) assert isinstance(result, web.Response) + mock_immich.people.async_get_person_thumbnail.side_effect = None + mock_immich.people.async_get_person_thumbnail.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + assert isinstance(result, web.Response) + + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", + ) + assert isinstance(result, web.Response) + mock_immich.assets.async_play_video_stream.side_effect = None mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( b"xxxx" From bf568b22d78c8ad5ec828a24abf9558243fe1615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 20:41:45 -1000 Subject: [PATCH 0716/1118] Bump onvif-zeep-async to 4.0.2 (#149606) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 63b7437be39..fbb1454ec2a 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==4.0.1", "WSDiscovery==2.1.2"] + "requirements": ["onvif-zeep-async==4.0.2", "WSDiscovery==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 943321cbf31..cc55ca31118 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1591,7 +1591,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de76a345a62..8db38e2466e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1359,7 +1359,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.0.14 # homeassistant.components.onvif -onvif-zeep-async==4.0.1 +onvif-zeep-async==4.0.2 # homeassistant.components.opengarage open-garage==0.2.0 From 3c1aa9d9dea96d16921153bd3077468b7263f2e1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 29 Jul 2025 08:52:42 +0200 Subject: [PATCH 0717/1118] Make exceptions translatable in Tankerkoenig integration (#149611) --- .../components/tankerkoenig/coordinator.py | 18 +++++++++++++++--- .../components/tankerkoenig/manifest.json | 2 +- .../components/tankerkoenig/quality_scale.yaml | 2 +- .../components/tankerkoenig/strings.json | 11 +++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index f1e6bc8c865..dbd826b9359 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -131,19 +131,31 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf stations, err, ) - raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except TankerkoenigRateLimitError as err: _LOGGER.warning( "API rate limit reached, consider to increase polling interval" ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="rate_limit_reached", + ) from err except (TankerkoenigError, TankerkoenigConnectionError) as err: _LOGGER.debug( "error occur during update of stations %s %s", stations, err, ) - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="station_update_failed", + translation_placeholders={ + "station_ids": ", ".join(stations), + }, + ) from err prices.update(data) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index 5dc75e4cc90..eeb8646bea7 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/homeassistant/components/tankerkoenig/quality_scale.yaml b/homeassistant/components/tankerkoenig/quality_scale.yaml index 666d927adb5..5def972b636 100644 --- a/homeassistant/components/tankerkoenig/quality_scale.yaml +++ b/homeassistant/components/tankerkoenig/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: exempt diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 3f821c7c6fa..43922a930af 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -180,5 +180,16 @@ } } } + }, + "exceptions": { + "rate_limit_reached": { + "message": "You have reached the rate limit for the Tankerkoenig API. Please try to increase the poll interval and reduce the requests." + }, + "invalid_api_key": { + "message": "The provided API key is invalid. Please check your API key." + }, + "station_update_failed": { + "message": "Failed to update station data for station(s) {station_ids}. Please check your network connection." + } } } From 62ee1fbc647ae683eb94080d9506ed3295ea471e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 29 Jul 2025 08:55:32 +0200 Subject: [PATCH 0718/1118] Remove unnecessary CONF_NAME usage in Habitica integration (#149595) --- homeassistant/components/habitica/config_flow.py | 2 -- homeassistant/components/habitica/coordinator.py | 7 ------- homeassistant/components/habitica/entity.py | 4 ++-- tests/components/habitica/test_config_flow.py | 5 ----- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 91a13bd7918..65d9be1bb7c 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -164,7 +164,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_API_USER: str(login.id), CONF_API_KEY: login.apiToken, - CONF_NAME: user.profile.name, # needed for api_call action CONF_URL: DEFAULT_URL, CONF_VERIFY_SSL: True, }, @@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): data={ **user_input, CONF_URL: user_input.get(CONF_URL, DEFAULT_URL), - CONF_NAME: user.profile.name, # needed for api_call action }, ) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index d0eb60312b4..b25edc7ceaf 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -23,7 +23,6 @@ from habiticalib import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -106,12 +105,6 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): translation_placeholders={"reason": str(e)}, ) from e - if not self.config_entry.data.get(CONF_NAME): - self.hass.config_entries.async_update_entry( - self.config_entry, - data={**self.config_entry.data, CONF_NAME: user.data.profile.name}, - ) - async def _async_update_data(self) -> HabiticaData: try: user = (await self.habitica.get_user()).data diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 692ea5e5ac1..6d320f93517 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from yarl import URL -from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.const import CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,7 +37,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): entry_type=DeviceEntryType.SERVICE, manufacturer=MANUFACTURER, model=NAME, - name=coordinator.config_entry.data[CONF_NAME], + name=coordinator.data.user.profile.name, configuration_url=( URL(coordinator.config_entry.data[CONF_URL]) / "profile" diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 5ec998ec82e..63001157695 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -16,7 +16,6 @@ from homeassistant.components.habitica.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, @@ -96,7 +95,6 @@ async def test_form_login(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> N CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -151,7 +149,6 @@ async def test_form_login_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -219,7 +216,6 @@ async def test_form_advanced(hass: HomeAssistant, mock_setup_entry: AsyncMock) - CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER @@ -275,7 +271,6 @@ async def test_form_advanced_errors( CONF_API_USER: TEST_API_USER, CONF_API_KEY: TEST_API_KEY, CONF_URL: DEFAULT_URL, - CONF_NAME: "test-user", CONF_VERIFY_SSL: True, } assert result["result"].unique_id == TEST_API_USER From 45ec9c7dad7e253285975176bc99d6ebc99cb078 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:37:32 +0200 Subject: [PATCH 0719/1118] Refactor coordinator setup in Iron OS (#149600) --- homeassistant/components/iron_os/__init__.py | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 7a0cf8eaa53..01ce0918459 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -9,9 +9,7 @@ from pynecil import IronOSUpdate, Pynecil from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import DOMAIN @@ -33,8 +31,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) @@ -42,19 +38,15 @@ IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up IronOS firmware update coordinator.""" - - session = async_get_clientsession(hass) - github = IronOSUpdate(session) - - hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) - await hass.data[IRON_OS_KEY].async_request_refresh() - return True - - async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Set up IronOS from a config entry.""" + if IRON_OS_KEY not in hass.data: + session = async_get_clientsession(hass) + github = IronOSUpdate(session) + + hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github) + await hass.data[IRON_OS_KEY].async_request_refresh() + if TYPE_CHECKING: assert entry.unique_id @@ -77,4 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if not hass.config_entries.async_loaded_entries(DOMAIN): + await hass.data[IRON_OS_KEY].async_shutdown() + hass.data.pop(IRON_OS_KEY) + return unload_ok From 2e728eb7de220e2aede1fcead1b744d36280a3c4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:38:50 +0200 Subject: [PATCH 0720/1118] Bump aioautomower to 2.1.1 (#149585) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index f5de5a3dff8..a0f25b1df4c 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.1.0"] + "requirements": ["aioautomower==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc55ca31118..4270e12d816 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.0 +aioautomower==2.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8db38e2466e..50fac6e7ea8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.0 +aioautomower==2.1.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 692a1119a6b7638fa0ea79121a9bf512ff751c58 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:29:07 +0200 Subject: [PATCH 0721/1118] Adjust suggested display precision on Volvo distance sensors (#149593) --- homeassistant/components/volvo/sensor.py | 11 +++ .../volvo/snapshots/test_sensor.ambr | 78 ++++++++++++------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index b8949f5e73d..dd982238a47 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -146,6 +146,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -154,6 +155,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # vehicle endpoint VolvoSensorDescription( @@ -170,6 +172,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # energy state endpoint VolvoSensorDescription( @@ -240,6 +243,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -248,6 +252,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -256,6 +261,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -280,6 +286,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME_STORAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # odometer endpoint VolvoSensorDescription( @@ -288,12 +295,14 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, ), # energy state endpoint VolvoSensorDescription( key="target_battery_charge_level", api_field="targetBatteryChargeLevel", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, ), # diagnostics endpoint VolvoSensorDescription( @@ -311,6 +320,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), # statistics endpoint VolvoSensorDescription( @@ -319,6 +329,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), ) diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 0f79ab5ca07..d5346cf9cd8 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -23,6 +23,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -550,7 +553,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -606,7 +609,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -718,7 +721,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -771,6 +774,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -935,7 +941,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -991,7 +997,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1099,7 +1105,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1155,7 +1161,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1210,6 +1216,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1328,7 +1337,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1384,7 +1393,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1440,7 +1449,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -1496,7 +1505,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -1664,7 +1673,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1720,7 +1729,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1828,7 +1837,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1884,7 +1893,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1939,6 +1948,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -2466,7 +2478,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2522,7 +2534,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2634,7 +2646,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -2687,6 +2699,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -2851,7 +2866,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2907,7 +2922,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3015,7 +3030,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3071,7 +3086,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3126,6 +3141,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -3244,7 +3262,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3300,7 +3318,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3356,7 +3374,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3412,7 +3430,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 1, }), }), 'original_device_class': , @@ -3580,7 +3598,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3636,7 +3654,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3744,7 +3762,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -3800,7 +3818,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , From 87400c6a1701b3dda756ccf143d3088f52bc5da4 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 29 Jul 2025 12:59:30 +0200 Subject: [PATCH 0722/1118] Bump odp-amsterdam to v6.1.2 (#149617) --- homeassistant/components/garages_amsterdam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 7652b4b6f3b..e74deac25c4 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.1.1"] + "requirements": ["odp-amsterdam==6.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4270e12d816..356e81fe73a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1570,7 +1570,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.oem oemthermostat==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50fac6e7ea8..e8d7c565659 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1341,7 +1341,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==6.1.1 +odp-amsterdam==6.1.2 # homeassistant.components.ohme ohme==1.5.1 From c7271d1af925742b71a142a054c43ae15649719b Mon Sep 17 00:00:00 2001 From: osohotwateriot <102795312+osohotwateriot@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:50:31 +0300 Subject: [PATCH 0723/1118] Add OSO Energy Custom Away Mode Service (#149612) --- homeassistant/components/osoenergy/icons.json | 3 +++ .../components/osoenergy/services.yaml | 14 +++++++++++ .../components/osoenergy/strings.json | 10 ++++++++ .../components/osoenergy/water_heater.py | 18 ++++++++++++++ .../components/osoenergy/test_water_heater.py | 24 +++++++++++++++++++ 5 files changed, 69 insertions(+) diff --git a/homeassistant/components/osoenergy/icons.json b/homeassistant/components/osoenergy/icons.json index 42d1f2cc480..be1bf0534db 100644 --- a/homeassistant/components/osoenergy/icons.json +++ b/homeassistant/components/osoenergy/icons.json @@ -22,6 +22,9 @@ "set_v40_min": { "service": "mdi:car-coolant-level" }, + "turn_away_mode_on": { + "service": "mdi:beach" + }, "turn_off": { "service": "mdi:water-boiler-off" }, diff --git a/homeassistant/components/osoenergy/services.yaml b/homeassistant/components/osoenergy/services.yaml index 6c8f5512215..4cd91f3285f 100644 --- a/homeassistant/components/osoenergy/services.yaml +++ b/homeassistant/components/osoenergy/services.yaml @@ -237,6 +237,20 @@ set_v40_min: max: 550 step: 1 unit_of_measurement: L +turn_away_mode_on: + target: + entity: + domain: water_heater + fields: + duration_days: + required: true + example: 7 + selector: + number: + min: 1 + max: 365 + step: 1 + unit_of_measurement: days turn_off: target: entity: diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 465f3f15c6b..60b67731eac 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -209,6 +209,16 @@ } } }, + "turn_away_mode_on": { + "name": "Set away mode", + "description": "Turns away mode on for the heater", + "fields": { + "duration_days": { + "name": "Duration in days", + "description": "Number of days to keep away mode active (1-365)" + } + } + }, "turn_off": { "name": "Turn off heating", "description": "Turns off heating for one hour or until min temperature is reached", diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index c271330bacd..1f4ad9d06c5 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -26,6 +26,7 @@ from homeassistant.util.json import JsonValueType from .const import DOMAIN from .entity import OSOEnergyEntity +ATTR_DURATION_DAYS = "duration_days" ATTR_UNTIL_TEMP_LIMIT = "until_temp_limit" ATTR_V40MIN = "v40_min" CURRENT_OPERATION_MAP: dict[str, Any] = { @@ -44,6 +45,7 @@ CURRENT_OPERATION_MAP: dict[str, Any] = { SERVICE_GET_PROFILE = "get_profile" SERVICE_SET_PROFILE = "set_profile" SERVICE_SET_V40MIN = "set_v40_min" +SERVICE_TURN_AWAY_MODE_ON = "turn_away_mode_on" SERVICE_TURN_OFF = "turn_off" SERVICE_TURN_ON = "turn_on" @@ -69,6 +71,16 @@ async def async_setup_entry( supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + SERVICE_TURN_AWAY_MODE_ON, + { + vol.Required(ATTR_DURATION_DAYS): vol.All( + vol.Coerce(int), vol.Range(min=1, max=365) + ), + }, + OSOEnergyWaterHeater.async_oso_turn_away_mode_on.__name__, + ) + service_set_profile_schema = cv.make_entity_service_schema( { vol.Optional(f"hour_{hour:02d}"): vol.All( @@ -280,6 +292,12 @@ class OSOEnergyWaterHeater( """Handle the service call.""" await self.osoenergy.hotwater.set_v40_min(self.entity_data, v40_min) + async def async_oso_turn_away_mode_on(self, duration_days: int) -> None: + """Enable away mode with duration.""" + await self.osoenergy.hotwater.enable_holiday_mode( + self.entity_data, duration_days + ) + async def async_oso_turn_off(self, until_temp_limit) -> None: """Handle the service call.""" await self.osoenergy.hotwater.turn_off(self.entity_data, until_temp_limit) diff --git a/tests/components/osoenergy/test_water_heater.py b/tests/components/osoenergy/test_water_heater.py index 270fc3c58f0..dd3a08dd24f 100644 --- a/tests/components/osoenergy/test_water_heater.py +++ b/tests/components/osoenergy/test_water_heater.py @@ -7,11 +7,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.osoenergy.const import DOMAIN from homeassistant.components.osoenergy.water_heater import ( + ATTR_DURATION_DAYS, ATTR_UNTIL_TEMP_LIMIT, ATTR_V40MIN, SERVICE_GET_PROFILE, SERVICE_SET_PROFILE, SERVICE_SET_V40MIN, + SERVICE_TURN_AWAY_MODE_ON, ) from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, @@ -310,3 +312,25 @@ async def test_turn_away_mode_off( ) mock_osoenergy_client().hotwater.disable_holiday_mode.assert_called_once_with(ANY) + + +async def test_oso_set_away_mode_on( + hass: HomeAssistant, + mock_osoenergy_client: MagicMock, + mock_config_entry: ConfigEntry, +) -> None: + """Test enabling away mode.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_AWAY_MODE_ON, + { + ATTR_ENTITY_ID: "water_heater.test_device", + ATTR_DURATION_DAYS: 10, + }, + blocking=True, + ) + + mock_osoenergy_client().hotwater.enable_holiday_mode.assert_called_once_with( + ANY, 10 + ) From 378c3af9dfd346008a9c752515f14c736ae6c75e Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 29 Jul 2025 13:51:32 +0200 Subject: [PATCH 0724/1118] Bump qbusmqttapi to 1.4.2 (#149622) --- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 17101da7c33..feffa6e492c 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.3.0"] + "requirements": ["qbusmqttapi==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 356e81fe73a..df48a2f43f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2624,7 +2624,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8d7c565659..3628cac38e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2176,7 +2176,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.3.0 +qbusmqttapi==1.4.2 # homeassistant.components.qingping qingping-ble==0.10.0 From 3d6f868cbcbcda635e832665db44faa10fcba51c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 29 Jul 2025 13:57:40 +0200 Subject: [PATCH 0725/1118] Bump zwave-js-server-python to 0.67.0 (#149616) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4c9ef784077..2cad8df3805 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.66.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index df48a2f43f3..1359413cd3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3212,7 +3212,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.66.0 +zwave-js-server-python==0.67.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3628cac38e9..31004789f97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeversolar==0.3.2 zha==0.0.62 # homeassistant.components.zwave_js -zwave-js-server-python==0.66.0 +zwave-js-server-python==0.67.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From ff7c1253348367174a12dc9307db2f73653e5a68 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 29 Jul 2025 15:19:08 +0200 Subject: [PATCH 0726/1118] Upgrade Homee quality scale to silver (#149194) --- homeassistant/components/homee/manifest.json | 2 +- .../components/homee/quality_scale.yaml | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 16169676835..9cac876f325 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["homee"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["pyHomee==1.2.10"] } diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index 906218cf823..5a8f987c1f9 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -28,16 +28,19 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: | + The integration does not have options. + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo - test-coverage: todo + reauthentication-flow: done + test-coverage: done # Gold devices: done @@ -49,16 +52,16 @@ rules: docs-known-limitations: todo docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo From 09e7d8d1a5525cdb4ab51bc58c496d8bc32a6246 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 29 Jul 2025 17:42:26 +0200 Subject: [PATCH 0727/1118] Increase open file descriptor limit on startup (#148940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jan Čermák Co-authored-by: Martin Hjelmare --- homeassistant/runner.py | 2 + homeassistant/util/resource.py | 65 ++++++++++++++ tests/util/test_resource.py | 153 +++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 homeassistant/util/resource.py create mode 100644 tests/util/test_resource.py diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 59775655854..abcf32f2659 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -17,6 +17,7 @@ from . import bootstrap from .core import callback from .helpers.frame import warn_use from .util.executor import InterruptibleThreadPoolExecutor +from .util.resource import set_open_file_descriptor_limit from .util.thread import deadlock_safe_shutdown # @@ -146,6 +147,7 @@ def _enable_posix_spawn() -> None: def run(runtime_config: RuntimeConfig) -> int: """Run Home Assistant.""" _enable_posix_spawn() + set_open_file_descriptor_limit() asyncio.set_event_loop_policy(HassEventLoopPolicy(runtime_config.debug)) # Backport of cpython 3.9 asyncio.run with a _cancel_all_tasks that times out loop = asyncio.new_event_loop() diff --git a/homeassistant/util/resource.py b/homeassistant/util/resource.py new file mode 100644 index 00000000000..41982df9e50 --- /dev/null +++ b/homeassistant/util/resource.py @@ -0,0 +1,65 @@ +"""Resource management utilities for Home Assistant.""" + +from __future__ import annotations + +import logging +import os +import resource +from typing import Final + +_LOGGER = logging.getLogger(__name__) + +# Default soft file descriptor limit to set +DEFAULT_SOFT_FILE_LIMIT: Final = 2048 + + +def set_open_file_descriptor_limit() -> None: + """Set the maximum open file descriptor soft limit.""" + try: + # Check environment variable first, then use default + soft_limit = int(os.environ.get("SOFT_FILE_LIMIT", DEFAULT_SOFT_FILE_LIMIT)) + + # Get current limits + current_soft, current_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + _LOGGER.debug( + "Current file descriptor limits: soft=%d, hard=%d", + current_soft, + current_hard, + ) + + # Don't increase if already at or above the desired limit + if current_soft >= soft_limit: + _LOGGER.debug( + "Current soft limit (%d) is already >= desired limit (%d), skipping", + current_soft, + soft_limit, + ) + return + + # Don't set soft limit higher than hard limit + if soft_limit > current_hard: + _LOGGER.warning( + "Requested soft limit (%d) exceeds hard limit (%d), " + "setting to hard limit", + soft_limit, + current_hard, + ) + soft_limit = current_hard + + # Set the new soft limit + resource.setrlimit(resource.RLIMIT_NOFILE, (soft_limit, current_hard)) + + # Verify the change + new_soft, new_hard = resource.getrlimit(resource.RLIMIT_NOFILE) + _LOGGER.info( + "File descriptor limits updated: soft=%d->%d, hard=%d", + current_soft, + new_soft, + new_hard, + ) + + except OSError as err: + _LOGGER.error("Failed to set file descriptor limit: %s", err) + except ValueError as err: + _LOGGER.error("Invalid file descriptor limit value: %s", err) diff --git a/tests/util/test_resource.py b/tests/util/test_resource.py new file mode 100644 index 00000000000..a32ceb1062c --- /dev/null +++ b/tests/util/test_resource.py @@ -0,0 +1,153 @@ +"""Test the resource utility module.""" + +import os +import resource +from unittest.mock import call, patch + +import pytest + +from homeassistant.util.resource import ( + DEFAULT_SOFT_FILE_LIMIT, + set_open_file_descriptor_limit, +) + + +@pytest.mark.parametrize( + ("original_soft", "expected_calls", "should_log_already_sufficient"), + [ + ( + 1024, + [call(resource.RLIMIT_NOFILE, (DEFAULT_SOFT_FILE_LIMIT, 524288))], + False, + ), + ( + DEFAULT_SOFT_FILE_LIMIT - 1, + [call(resource.RLIMIT_NOFILE, (DEFAULT_SOFT_FILE_LIMIT, 524288))], + False, + ), + (DEFAULT_SOFT_FILE_LIMIT, [], True), + (DEFAULT_SOFT_FILE_LIMIT + 1, [], True), + ], +) +def test_set_open_file_descriptor_limit_default( + caplog: pytest.LogCaptureFixture, + original_soft: int, + expected_calls: list, + should_log_already_sufficient: bool, +) -> None: + """Test setting file limit with default value.""" + original_hard = 524288 + with ( + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + assert mock_setrlimit.call_args_list == expected_calls + assert ( + f"Current soft limit ({original_soft}) is already" in caplog.text + ) is should_log_already_sufficient + + +@pytest.mark.parametrize( + ( + "original_soft", + "custom_limit", + "expected_calls", + "should_log_already_sufficient", + ), + [ + (1499, 1500, [call(resource.RLIMIT_NOFILE, (1500, 524288))], False), + (1500, 1500, [], True), + (1501, 1500, [], True), + ], +) +def test_set_open_file_descriptor_limit_environment_variable( + caplog: pytest.LogCaptureFixture, + original_soft: int, + custom_limit: int, + expected_calls: list, + should_log_already_sufficient: bool, +) -> None: + """Test setting file limit from environment variable.""" + original_hard = 524288 + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(custom_limit)}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + assert mock_setrlimit.call_args_list == expected_calls + assert ( + f"Current soft limit ({original_soft}) is already" in caplog.text + ) is should_log_already_sufficient + + +def test_set_open_file_descriptor_limit_exceeds_hard_limit( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setting file limit that exceeds hard limit.""" + original_soft, original_hard = (1024, 524288) + excessive_limit = original_hard + 1 + + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": str(excessive_limit)}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(original_soft, original_hard), + ), + patch("homeassistant.util.resource.resource.setrlimit") as mock_setrlimit, + ): + set_open_file_descriptor_limit() + + mock_setrlimit.assert_called_once_with( + resource.RLIMIT_NOFILE, (original_hard, original_hard) + ) + assert ( + f"Requested soft limit ({excessive_limit}) exceeds hard limit ({original_hard})" + in caplog.text + ) + + +def test_set_open_file_descriptor_limit_os_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling OSError when setting file limit.""" + with ( + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(1024, 524288), + ), + patch( + "homeassistant.util.resource.resource.setrlimit", + side_effect=OSError("Permission denied"), + ), + ): + set_open_file_descriptor_limit() + + assert "Failed to set file descriptor limit" in caplog.text + assert "Permission denied" in caplog.text + + +def test_set_open_file_descriptor_limit_value_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling ValueError when setting file limit.""" + with ( + patch.dict(os.environ, {"SOFT_FILE_LIMIT": "invalid_value"}), + patch( + "homeassistant.util.resource.resource.getrlimit", + return_value=(1024, 524288), + ), + ): + set_open_file_descriptor_limit() + + assert "Invalid file descriptor limit value" in caplog.text + assert "'invalid_value'" in caplog.text From 25407c0f4b5d3ef0e3e9faaf1c74cc6b466cbd5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 07:21:31 -1000 Subject: [PATCH 0728/1118] Bump aiohttp to 3.12.15 (#149609) --- 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 a43eadce0de..24c107e5611 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.14 +aiohttp==3.12.15 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index d15a93fd8bd..35a2bf2c7fb 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.1", - "aiohttp==3.12.14", + "aiohttp==3.12.15", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 6110854f5f6..a332eb930c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.1 -aiohttp==3.12.14 +aiohttp==3.12.15 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From b67e85e8dae75e52e5b56e0ca616364816277432 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 29 Jul 2025 19:41:13 +0200 Subject: [PATCH 0729/1118] Introduce Ubiquiti UISP airOS (#148989) Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/ubiquiti.json | 2 +- homeassistant/components/airos/__init__.py | 42 ++ homeassistant/components/airos/config_flow.py | 82 +++ homeassistant/components/airos/const.py | 9 + homeassistant/components/airos/coordinator.py | 66 +++ homeassistant/components/airos/entity.py | 36 ++ homeassistant/components/airos/manifest.json | 10 + .../components/airos/quality_scale.yaml | 72 +++ homeassistant/components/airos/sensor.py | 152 +++++ homeassistant/components/airos/strings.json | 87 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airos/__init__.py | 13 + tests/components/airos/conftest.py | 61 ++ .../airos/fixtures/airos_ap-ptp.json | 300 ++++++++++ .../airos/snapshots/test_sensor.ambr | 547 ++++++++++++++++++ tests/components/airos/test_config_flow.py | 119 ++++ tests/components/airos/test_sensor.py | 85 +++ 23 files changed, 1708 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airos/__init__.py create mode 100644 homeassistant/components/airos/config_flow.py create mode 100644 homeassistant/components/airos/const.py create mode 100644 homeassistant/components/airos/coordinator.py create mode 100644 homeassistant/components/airos/entity.py create mode 100644 homeassistant/components/airos/manifest.json create mode 100644 homeassistant/components/airos/quality_scale.yaml create mode 100644 homeassistant/components/airos/sensor.py create mode 100644 homeassistant/components/airos/strings.json create mode 100644 tests/components/airos/__init__.py create mode 100644 tests/components/airos/conftest.py create mode 100644 tests/components/airos/fixtures/airos_ap-ptp.json create mode 100644 tests/components/airos/snapshots/test_sensor.ambr create mode 100644 tests/components/airos/test_config_flow.py create mode 100644 tests/components/airos/test_sensor.py diff --git a/.strict-typing b/.strict-typing index c6e27a011f1..c125e85bbfc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -53,6 +53,7 @@ homeassistant.components.air_quality.* homeassistant.components.airgradient.* homeassistant.components.airly.* homeassistant.components.airnow.* +homeassistant.components.airos.* homeassistant.components.airq.* homeassistant.components.airthings.* homeassistant.components.airthings_ble.* diff --git a/CODEOWNERS b/CODEOWNERS index 4e7c1b9175a..5ef8479d4d3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -67,6 +67,8 @@ build.json @home-assistant/supervisor /tests/components/airly/ @bieniu /homeassistant/components/airnow/ @asymworks /tests/components/airnow/ @asymworks +/homeassistant/components/airos/ @CoMPaTech +/tests/components/airos/ @CoMPaTech /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen @LaStrada diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index 8b64cffaa7e..bb345775a60 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -1,5 +1,5 @@ { "domain": "ubiquiti", "name": "Ubiquiti", - "integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"] + "integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"] } diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py new file mode 100644 index 00000000000..54f0db205a9 --- /dev/null +++ b/homeassistant/components/airos/__init__.py @@ -0,0 +1,42 @@ +"""The Ubiquiti airOS integration.""" + +from __future__ import annotations + +from airos.airos8 import AirOS + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Set up Ubiquiti airOS from a config entry.""" + + # By default airOS 8 comes with self-signed SSL certificates, + # with no option in the web UI to change or upload a custom certificate. + session = async_get_clientsession(hass, verify_ssl=False) + + airos_device = AirOS( + host=entry.data[CONF_HOST], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=session, + ) + + coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py new file mode 100644 index 00000000000..287f54101c8 --- /dev/null +++ b/homeassistant/components/airos/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for the Ubiquiti airOS integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from airos.exceptions import ( + ConnectionAuthenticationError, + ConnectionSetupError, + DataMissingError, + DeviceConnectionError, + KeyDataMissingError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import AirOS + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME, default="ubnt"): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ubiquiti airOS.""" + + VERSION = 1 + + 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: + # By default airOS 8 comes with self-signed SSL certificates, + # with no option in the web UI to change or upload a custom certificate. + session = async_get_clientsession(self.hass, verify_ssl=False) + + airos_device = AirOS( + host=user_input[CONF_HOST], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + session=session, + ) + try: + await airos_device.login() + airos_data = await airos_device.status() + + except ( + ConnectionSetupError, + DeviceConnectionError, + ): + errors["base"] = "cannot_connect" + except (ConnectionAuthenticationError, DataMissingError): + errors["base"] = "invalid_auth" + except KeyDataMissingError: + errors["base"] = "key_data_missing" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(airos_data.derived.mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=airos_data.host.hostname, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py new file mode 100644 index 00000000000..f4be2594613 --- /dev/null +++ b/homeassistant/components/airos/const.py @@ -0,0 +1,9 @@ +"""Constants for the Ubiquiti airOS integration.""" + +from datetime import timedelta + +DOMAIN = "airos" + +SCAN_INTERVAL = timedelta(minutes=1) + +MANUFACTURER = "Ubiquiti" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py new file mode 100644 index 00000000000..3f0f1a12380 --- /dev/null +++ b/homeassistant/components/airos/coordinator.py @@ -0,0 +1,66 @@ +"""DataUpdateCoordinator for AirOS.""" + +from __future__ import annotations + +import logging + +from airos.airos8 import AirOS, AirOSData +from airos.exceptions import ( + ConnectionAuthenticationError, + ConnectionSetupError, + DataMissingError, + DeviceConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] + + +class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): + """Class to manage fetching AirOS data from single endpoint.""" + + config_entry: AirOSConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS + ) -> None: + """Initialize the coordinator.""" + self.airos_device = airos_device + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> AirOSData: + """Fetch data from AirOS.""" + try: + await self.airos_device.login() + return await self.airos_device.status() + except (ConnectionAuthenticationError,) as err: + _LOGGER.exception("Error authenticating with airOS device") + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err: + _LOGGER.error("Error connecting to airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except (DataMissingError,) as err: + _LOGGER.error("Expected data not returned by airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_data_missing", + ) from err diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py new file mode 100644 index 00000000000..e54962110fc --- /dev/null +++ b/homeassistant/components/airos/entity.py @@ -0,0 +1,36 @@ +"""Generic AirOS Entity Class.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirOSDataUpdateCoordinator + + +class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]): + """Represent a AirOS Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None: + """Initialise the gateway.""" + super().__init__(coordinator) + + airos_data = self.coordinator.data + + configuration_url: str | None = ( + f"https://{coordinator.config_entry.data[CONF_HOST]}" + ) + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)}, + configuration_url=configuration_url, + identifiers={(DOMAIN, str(airos_data.host.device_id))}, + manufacturer=MANUFACTURER, + model=airos_data.host.devmodel, + name=airos_data.host.hostname, + sw_version=airos_data.host.fwversion, + ) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json new file mode 100644 index 00000000000..cb6119a6fa9 --- /dev/null +++ b/homeassistant/components/airos/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airos", + "name": "Ubiquiti airOS", + "codeowners": ["@CoMPaTech"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airos", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["airos==0.2.1"] +} diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml new file mode 100644 index 00000000000..a0bacd5ebba --- /dev/null +++ b/homeassistant/components/airos/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: airOS does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: airOS does not have actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: local_polling without events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: airOS does not have actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: todo + comment: prepared binary_sensors will provide this + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: no (custom) icons used or envisioned + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py new file mode 100644 index 00000000000..690bf21fc8e --- /dev/null +++ b/homeassistant/components/airos/sensor.py @@ -0,0 +1,152 @@ +"""AirOS Sensor component for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from airos.data import NetRole, WirelessMode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + UnitOfDataRate, + UnitOfFrequency, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .entity import AirOSEntity + +_LOGGER = logging.getLogger(__name__) + +WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode] +NETROLE_OPTIONS = [mode.value for mode in NetRole] + + +@dataclass(frozen=True, kw_only=True) +class AirOSSensorEntityDescription(SensorEntityDescription): + """Describe an AirOS sensor.""" + + value_fn: Callable[[AirOSData], StateType] + + +SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( + AirOSSensorEntityDescription( + key="host_cpuload", + translation_key="host_cpuload", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.host.cpuload, + entity_registry_enabled_default=False, + ), + AirOSSensorEntityDescription( + key="host_netrole", + translation_key="host_netrole", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.host.netrole.value, + options=NETROLE_OPTIONS, + ), + AirOSSensorEntityDescription( + key="wireless_frequency", + translation_key="wireless_frequency", + native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.frequency, + ), + AirOSSensorEntityDescription( + key="wireless_essid", + translation_key="wireless_essid", + value_fn=lambda data: data.wireless.essid, + ), + AirOSSensorEntityDescription( + key="wireless_mode", + translation_key="wireless_mode", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(), + options=WIRELESS_MODE_OPTIONS, + ), + AirOSSensorEntityDescription( + key="wireless_antenna_gain", + translation_key="wireless_antenna_gain", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.antenna_gain, + ), + AirOSSensorEntityDescription( + key="wireless_throughput_tx", + translation_key="wireless_throughput_tx", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.throughput.tx, + ), + AirOSSensorEntityDescription( + key="wireless_throughput_rx", + translation_key="wireless_throughput_rx", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.throughput.rx, + ), + AirOSSensorEntityDescription( + key="wireless_polling_dl_capacity", + translation_key="wireless_polling_dl_capacity", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.polling.dl_capacity, + ), + AirOSSensorEntityDescription( + key="wireless_polling_ul_capacity", + translation_key="wireless_polling_ul_capacity", + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.wireless.polling.ul_capacity, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS sensors from a config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS) + + +class AirOSSensor(AirOSEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: AirOSSensorEntityDescription + + def __init__( + self, + coordinator: AirOSDataUpdateCoordinator, + description: AirOSSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json new file mode 100644 index 00000000000..6823ba8520b --- /dev/null +++ b/homeassistant/components/airos/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "flow_title": "Ubiquiti airOS device", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of the airOS device", + "username": "Administrator username for the airOS device, normally 'ubnt'", + "password": "Password configured through the UISP app or web interface" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "key_data_missing": "Expected data not returned from the device, check the documentation for supported devices", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "host_cpuload": { + "name": "CPU load" + }, + "host_netrole": { + "name": "Network role", + "state": { + "bridge": "Bridge", + "router": "Router" + } + }, + "wireless_frequency": { + "name": "Wireless frequency" + }, + "wireless_essid": { + "name": "Wireless SSID" + }, + "wireless_mode": { + "name": "Wireless mode", + "state": { + "ap_ptp": "Access point", + "sta_ptp": "Station" + } + }, + "wireless_antenna_gain": { + "name": "Antenna gain" + }, + "wireless_throughput_tx": { + "name": "Throughput transmit (actual)" + }, + "wireless_throughput_rx": { + "name": "Throughput receive (actual)" + }, + "wireless_polling_dl_capacity": { + "name": "Download capacity" + }, + "wireless_polling_ul_capacity": { + "name": "Upload capacity" + }, + "wireless_remote_hostname": { + "name": "Remote hostname" + } + } + }, + "exceptions": { + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "key_data_missing": { + "message": "Key data not returned from device" + }, + "error_data_missing": { + "message": "Data incomplete or missing" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5d468fd1dc9..5816a0ddbd9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -37,6 +37,7 @@ FLOWS = { "airgradient", "airly", "airnow", + "airos", "airq", "airthings", "airthings_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a673b05218d..5f4ae434074 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7002,6 +7002,12 @@ "ubiquiti": { "name": "Ubiquiti", "integrations": { + "airos": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Ubiquiti airOS" + }, "unifi": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index ba5ac08d3c9..8482138cc45 100644 --- a/mypy.ini +++ b/mypy.ini @@ -285,6 +285,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airos.*] +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.airq.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1359413cd3a..abb0e9ded9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -452,6 +452,9 @@ airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airos +airos==0.2.1 + # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31004789f97..8c544ff5a88 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -434,6 +434,9 @@ airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airos +airos==0.2.1 + # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/__init__.py b/tests/components/airos/__init__.py new file mode 100644 index 00000000000..8c6182a8650 --- /dev/null +++ b/tests/components/airos/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Ubiquity airOS integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py new file mode 100644 index 00000000000..b17908e801a --- /dev/null +++ b/tests/components/airos/conftest.py @@ -0,0 +1,61 @@ +"""Common fixtures for the Ubiquiti airOS tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from airos.airos8 import AirOSData +import pytest + +from homeassistant.components.airos.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def ap_fixture(): + """Load fixture data for AP mode.""" + json_data = load_json_object_fixture("airos_ap-ptp.json", DOMAIN) + return AirOSData.from_dict(json_data) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airos.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_airos_client( + request: pytest.FixtureRequest, ap_fixture: AirOSData +) -> Generator[AsyncMock]: + """Fixture to mock the AirOS API client.""" + with ( + patch( + "homeassistant.components.airos.config_flow.AirOS", autospec=True + ) as mock_airos, + patch("homeassistant.components.airos.coordinator.AirOS", new=mock_airos), + patch("homeassistant.components.airos.AirOS", new=mock_airos), + ): + client = mock_airos.return_value + client.status.return_value = ap_fixture + client.login.return_value = True + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the AirOS mocked config entry.""" + return MockConfigEntry( + title="NanoStation", + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "ubnt", + }, + unique_id="01:23:45:67:89:AB", + ) diff --git a/tests/components/airos/fixtures/airos_ap-ptp.json b/tests/components/airos/fixtures/airos_ap-ptp.json new file mode 100644 index 00000000000..06d13ba1101 --- /dev/null +++ b/tests/components/airos/fixtures/airos_ap-ptp.json @@ -0,0 +1,300 @@ +{ + "chain_names": [ + { "number": 1, "name": "Chain 0" }, + { "number": 2, "name": "Chain 1" } + ], + "host": { + "hostname": "NanoStation 5AC ap name", + "device_id": "03aa0d0b40fed0a47088293584ef5432", + "uptime": 264888, + "power_time": 268683, + "time": "2025-06-23 23:06:42", + "timestamp": 2668313184, + "fwversion": "v8.7.17", + "devmodel": "NanoStation 5AC loco", + "netrole": "bridge", + "loadavg": 0.412598, + "totalram": 63447040, + "freeram": 16564224, + "temperature": 0, + "cpuload": 10.10101, + "height": 3 + }, + "genuine": "/images/genuine.png", + "services": { + "dhcpc": false, + "dhcpd": false, + "dhcp6d_stateful": false, + "pppoe": false, + "airview": 2 + }, + "firewall": { + "iptables": false, + "ebtables": false, + "ip6tables": false, + "eb6tables": false + }, + "portfw": false, + "wireless": { + "essid": "DemoSSID", + "mode": "ap-ptp", + "ieeemode": "11ACVHT80", + "band": 2, + "compat_11n": 0, + "hide_essid": 0, + "apmac": "01:23:45:67:89:AB", + "antenna_gain": 13, + "frequency": 5500, + "center1_freq": 5530, + "dfs": 1, + "distance": 0, + "security": "WPA2", + "noisef": -89, + "txpower": -3, + "aprepeater": false, + "rstatus": 5, + "chanbw": 80, + "rx_chainmask": 3, + "tx_chainmask": 3, + "nol_state": 0, + "nol_timeout": 0, + "cac_state": 0, + "cac_timeout": 0, + "rx_idx": 8, + "rx_nss": 2, + "tx_idx": 9, + "tx_nss": 2, + "throughput": { "tx": 222, "rx": 9907 }, + "service": { "time": 267181, "link": 266003 }, + "polling": { + "cb_capacity": 593970, + "dl_capacity": 647400, + "ul_capacity": 540540, + "use": 48, + "tx_use": 6, + "rx_use": 42, + "atpc_status": 2, + "fixed_frame": false, + "gps_sync": false, + "ff_cap_rep": false + }, + "count": 1, + "sta": [ + { + "mac": "01:23:45:67:89:AB", + "lastip": "192.168.1.2", + "signal": -59, + "rssi": 37, + "noisefloor": -89, + "chainrssi": [35, 32, 0], + "tx_idx": 9, + "rx_idx": 8, + "tx_nss": 2, + "rx_nss": 2, + "tx_latency": 0, + "distance": 1, + "tx_packets": 0, + "tx_lretries": 0, + "tx_sretries": 0, + "uptime": 170281, + "dl_signal_expect": -80, + "ul_signal_expect": -55, + "cb_capacity_expect": 416000, + "dl_capacity_expect": 208000, + "ul_capacity_expect": 624000, + "dl_rate_expect": 3, + "ul_rate_expect": 8, + "dl_linkscore": 100, + "ul_linkscore": 86, + "dl_avg_linkscore": 100, + "ul_avg_linkscore": 88, + "tx_ratedata": [175, 4, 47, 200, 673, 158, 163, 138, 68895, 19577430], + "stats": { + "rx_bytes": 206938324814, + "rx_packets": 149767200, + "rx_pps": 846, + "tx_bytes": 5265602739, + "tx_packets": 52980390, + "tx_pps": 0 + }, + "airmax": { + "actual_priority": 0, + "beam": 0, + "desired_priority": 0, + "cb_capacity": 593970, + "dl_capacity": 647400, + "ul_capacity": 540540, + "atpc_status": 2, + "rx": { + "usage": 42, + "cinr": 31, + "evm": [ + [ + 31, 28, 33, 32, 32, 32, 31, 31, 31, 29, 30, 32, 30, 27, 34, 31, + 31, 30, 32, 29, 31, 29, 31, 33, 31, 31, 32, 30, 31, 34, 33, 31, + 30, 31, 30, 31, 31, 32, 31, 30, 33, 31, 30, 31, 27, 31, 30, 30, + 30, 30, 30, 29, 32, 34, 31, 30, 28, 30, 29, 35, 31, 33, 32, 29 + ], + [ + 34, 34, 35, 34, 35, 35, 34, 34, 34, 34, 34, 34, 34, 34, 35, 35, + 34, 34, 35, 34, 33, 33, 35, 34, 34, 35, 34, 35, 34, 34, 35, 34, + 34, 33, 34, 34, 34, 34, 34, 35, 35, 35, 34, 35, 33, 34, 34, 34, + 34, 35, 35, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 35, 35 + ] + ] + }, + "tx": { + "usage": 6, + "cinr": 31, + "evm": [ + [ + 32, 34, 28, 33, 35, 30, 31, 33, 30, 30, 32, 30, 29, 33, 31, 29, + 33, 31, 31, 30, 33, 34, 33, 31, 33, 32, 32, 31, 29, 31, 30, 32, + 31, 30, 29, 32, 31, 32, 31, 31, 32, 29, 31, 29, 30, 32, 32, 31, + 32, 32, 33, 31, 28, 29, 31, 31, 33, 32, 33, 32, 32, 32, 31, 33 + ], + [ + 37, 37, 37, 38, 38, 37, 36, 38, 38, 37, 37, 37, 37, 37, 39, 37, + 37, 37, 37, 37, 37, 36, 37, 37, 37, 37, 37, 37, 37, 38, 37, 37, + 38, 37, 37, 37, 38, 37, 38, 37, 37, 37, 37, 37, 36, 37, 37, 37, + 37, 37, 37, 38, 37, 37, 38, 37, 36, 37, 37, 37, 37, 37, 37, 37 + ] + ] + } + }, + "last_disc": 1, + "remote": { + "age": 1, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "hostname": "NanoStation 5AC sta name", + "platform": "NanoStation 5AC loco", + "version": "WA.ar934x.v8.7.17.48152.250620.2132", + "time": "2025-06-23 23:13:54", + "cpuload": 43.564301, + "temperature": 0, + "totalram": 63447040, + "freeram": 14290944, + "netrole": "bridge", + "mode": "sta-ptp", + "sys_id": "0xe7fa", + "tx_throughput": 16023, + "rx_throughput": 251, + "uptime": 265320, + "power_time": 268512, + "compat_11n": 0, + "signal": -58, + "rssi": 38, + "noisefloor": -90, + "tx_power": -4, + "distance": 1, + "rx_chainmask": 3, + "chainrssi": [33, 37, 0], + "tx_ratedata": [ + 14, 4, 372, 2223, 4708, 4037, 8142, 485763, 29420892, 24748154 + ], + "tx_bytes": 212308148210, + "rx_bytes": 3624206478, + "antenna_gain": 13, + "cable_loss": 0, + "height": 2, + "ethlist": [ + { + "ifname": "eth0", + "enabled": true, + "plugged": true, + "duplex": true, + "speed": 1000, + "snr": [30, 30, 29, 30], + "cable_len": 14 + } + ], + "ipaddr": ["192.168.1.2"], + "ip6addr": ["fe80::eea:14ff:fea4:89ab"], + "gps": { "lat": "52.379894", "lon": "4.901608", "fix": 0 }, + "oob": false, + "unms": { "status": 0, "timestamp": null }, + "airview": 2, + "service": { "time": 267195, "link": 265996 } + }, + "airos_connected": true + } + ], + "sta_disconnected": [] + }, + "interfaces": [ + { + "ifname": "eth0", + "hwaddr": "01:23:45:67:89:AB", + "enabled": true, + "mtu": 1500, + "status": { + "plugged": true, + "tx_bytes": 209900085624, + "rx_bytes": 3984971949, + "tx_packets": 185866883, + "rx_packets": 73564835, + "tx_errors": 0, + "rx_errors": 4, + "tx_dropped": 10, + "rx_dropped": 0, + "ipaddr": "0.0.0.0", + "speed": 1000, + "duplex": true, + "snr": [30, 30, 30, 30], + "cable_len": 18, + "ip6addr": null + } + }, + { + "ifname": "ath0", + "hwaddr": "01:23:45:67:89:AB", + "enabled": true, + "mtu": 1500, + "status": { + "plugged": false, + "tx_bytes": 5265602738, + "rx_bytes": 206938324766, + "tx_packets": 52980390, + "rx_packets": 149767200, + "tx_errors": 0, + "rx_errors": 0, + "tx_dropped": 2005, + "rx_dropped": 0, + "ipaddr": "0.0.0.0", + "speed": 0, + "duplex": false, + "snr": null, + "cable_len": null, + "ip6addr": null + } + }, + { + "ifname": "br0", + "hwaddr": "01:23:45:67:89:AB", + "enabled": true, + "mtu": 1500, + "status": { + "plugged": true, + "tx_bytes": 236295176, + "rx_bytes": 204802727, + "tx_packets": 298119, + "rx_packets": 1791592, + "tx_errors": 0, + "rx_errors": 0, + "tx_dropped": 0, + "rx_dropped": 0, + "ipaddr": "192.168.1.2", + "speed": 0, + "duplex": false, + "snr": null, + "cable_len": null, + "ip6addr": [{ "addr": "fe80::eea:14ff:fea4:89cd", "plen": 64 }] + } + } + ], + "provmode": {}, + "ntpclient": {}, + "unms": { "status": 0, "timestamp": null }, + "gps": { "lat": 52.379894, "lon": 4.901608, "fix": 0 }, + "derived": { "mac": "01:23:45:67:89:AB", "mac_interface": "br0" } +} diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a92d2dc35a2 --- /dev/null +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -0,0 +1,547 @@ +# serializer version: 1 +# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-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.nanostation_5ac_ap_name_antenna_gain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Antenna gain', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_antenna_gain', + 'unique_id': '01:23:45:67:89:AB_wireless_antenna_gain', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_antenna_gain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'NanoStation 5AC ap name Antenna gain', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_antenna_gain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-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.nanostation_5ac_ap_name_cpu_load', + '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': 'CPU load', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_cpuload', + 'unique_id': '01:23:45:67:89:AB_host_cpuload', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_cpu_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name CPU load', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_cpu_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.10101', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_download_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': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download capacity', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_polling_dl_capacity', + 'unique_id': '01:23:45:67:89:AB_wireless_polling_dl_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Download capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '647400', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'bridge', + 'router', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_network_role', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Network role', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'host_netrole', + 'unique_id': '01:23:45:67:89:AB_host_netrole', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Network role', + 'options': list([ + 'bridge', + 'router', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_network_role', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bridge', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-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.nanostation_5ac_ap_name_throughput_receive_actual', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Throughput receive (actual)', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_throughput_rx', + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_rx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Throughput receive (actual)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9907', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-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.nanostation_5ac_ap_name_throughput_transmit_actual', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Throughput transmit (actual)', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_throughput_tx', + 'unique_id': '01:23:45:67:89:AB_wireless_throughput_tx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Throughput transmit (actual)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '222', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_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': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload capacity', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_polling_ul_capacity', + 'unique_id': '01:23:45:67:89:AB_wireless_polling_ul_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'NanoStation 5AC ap name Upload capacity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540540', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-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.nanostation_5ac_ap_name_wireless_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless frequency', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_frequency', + 'unique_id': '01:23:45:67:89:AB_wireless_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'NanoStation 5AC ap name Wireless frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5500', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ap_ptp', + 'sta_ptp', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wireless mode', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_mode', + 'unique_id': '01:23:45:67:89:AB_wireless_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'NanoStation 5AC ap name Wireless mode', + 'options': list([ + 'ap_ptp', + 'sta_ptp', + ]), + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ap_ptp', + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-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.nanostation_5ac_ap_name_wireless_ssid', + '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': 'Wireless SSID', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wireless_essid', + 'unique_id': '01:23:45:67:89:AB_wireless_essid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name Wireless SSID', + }), + 'context': , + 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'DemoSSID', + }) +# --- diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py new file mode 100644 index 00000000000..9d2a6376732 --- /dev/null +++ b/tests/components/airos/test_config_flow.py @@ -0,0 +1,119 @@ +"""Test the Ubiquiti airOS config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +from airos.exceptions import ( + ConnectionAuthenticationError, + DeviceConnectionError, + KeyDataMissingError, +) +import pytest + +from homeassistant.components.airos.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "ubnt", + CONF_PASSWORD: "test-password", +} + + +async def test_form_creates_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airos_client: AsyncMock, + ap_fixture: dict[str, Any], +) -> None: + """Test we get the form and create the appropriate entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "NanoStation 5AC ap name" + assert result["result"].unique_id == "01:23:45:67:89:AB" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicate_entry( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the form does not allow duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ConnectionAuthenticationError, "invalid_auth"), + (DeviceConnectionError, "cannot_connect"), + (KeyDataMissingError, "key_data_missing"), + (Exception, "unknown"), + ], +) +async def test_form_exception_handling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_airos_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle exceptions.""" + mock_airos_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_airos_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "NanoStation 5AC ap name" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py new file mode 100644 index 00000000000..561741b1a2b --- /dev/null +++ b/tests/components/airos/test_sensor.py @@ -0,0 +1,85 @@ +"""Test the Ubiquiti airOS sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from airos.exceptions import ( + ConnectionAuthenticationError, + DataMissingError, + DeviceConnectionError, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.airos.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("exception"), + [ + ConnectionAuthenticationError, + TimeoutError, + DeviceConnectionError, + DataMissingError, + ], +) +async def test_sensor_update_exception_handling( + hass: HomeAssistant, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity update data handles exceptions.""" + await setup_integration(hass, mock_config_entry) + + expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain" + signal_state = hass.states.get(expected_entity_id) + + assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" + assert signal_state.attributes.get("unit_of_measurement") == "dB", ( + f"Expected unit 'dB', got {signal_state.attributes.get('unit_of_measurement')}" + ) + + mock_airos_client.login.side_effect = exception + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + signal_state = hass.states.get(expected_entity_id) + + assert signal_state.state == STATE_UNAVAILABLE, ( + f"Expected state {STATE_UNAVAILABLE}, got {signal_state.state}" + ) + + mock_airos_client.login.side_effect = None + + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + signal_state = hass.states.get(expected_entity_id) + assert signal_state.state == "13", f"Expected state 13, got {signal_state.state}" From aaec243bf49ccaaf73a7ebe730ab46c1dbff2aa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 29 Jul 2025 07:49:20 -1000 Subject: [PATCH 0730/1118] Properly cleanup ONVIF events to prevent log flooding on setup errors (#149603) --- homeassistant/components/onvif/__init__.py | 107 +++++++++++---------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 057993be181..83dc238d2c4 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,7 +1,7 @@ """The ONVIF integration.""" import asyncio -from contextlib import suppress +from contextlib import AsyncExitStack, suppress from http import HTTPStatus import logging @@ -45,50 +45,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = ONVIFDevice(hass, entry) - try: - await device.async_setup() - if not entry.data.get(CONF_SNAPSHOT_AUTH): - await async_populate_snapshot_auth(hass, device, entry) - except (TimeoutError, aiohttp.ClientError) as err: - await device.device.close() - raise ConfigEntryNotReady( - f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" - ) from err - except Fault as err: - await device.device.close() - if is_auth_error(err): - raise ConfigEntryAuthFailed( - f"Auth Failed: {stringify_onvif_error(err)}" - ) from err - raise ConfigEntryNotReady( - f"Could not connect to camera: {stringify_onvif_error(err)}" - ) from err - except ONVIFError as err: - await device.device.close() - raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" - ) from err - except TransportError as err: - await device.device.close() - stringified_onvif_error = stringify_onvif_error(err) - if err.status_code in ( - HTTPStatus.UNAUTHORIZED.value, - HTTPStatus.FORBIDDEN.value, - ): - raise ConfigEntryAuthFailed( - f"Auth Failed: {stringified_onvif_error}" - ) from err - raise ConfigEntryNotReady( - f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" - ) from err - except asyncio.CancelledError as err: - # After https://github.com/agronholm/anyio/issues/374 is resolved - # this may be able to be removed - await device.device.close() - raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err + async with AsyncExitStack() as stack: + # Register cleanup callback for device + @stack.push_async_callback + async def _cleanup(): + await _async_stop_device(hass, device) - if not device.available: - raise ConfigEntryNotReady + try: + await device.async_setup() + if not entry.data.get(CONF_SNAPSHOT_AUTH): + await async_populate_snapshot_auth(hass, device, entry) + except (TimeoutError, aiohttp.ClientError) as err: + raise ConfigEntryNotReady( + f"Could not connect to camera {device.device.host}:{device.device.port}: {err}" + ) from err + except Fault as err: + if is_auth_error(err): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringify_onvif_error(err)}" + ) from err + raise ConfigEntryNotReady( + f"Could not connect to camera: {stringify_onvif_error(err)}" + ) from err + except ONVIFError as err: + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}" + ) from err + except TransportError as err: + stringified_onvif_error = stringify_onvif_error(err) + if err.status_code in ( + HTTPStatus.UNAUTHORIZED.value, + HTTPStatus.FORBIDDEN.value, + ): + raise ConfigEntryAuthFailed( + f"Auth Failed: {stringified_onvif_error}" + ) from err + raise ConfigEntryNotReady( + f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}" + ) from err + except asyncio.CancelledError as err: + # After https://github.com/agronholm/anyio/issues/374 is resolved + # this may be able to be removed + raise ConfigEntryNotReady( + f"Setup was unexpectedly canceled: {err}" + ) from err + + if not device.available: + raise ConfigEntryNotReady + + # If we get here, setup was successful - prevent cleanup + stack.pop_all() hass.data[DOMAIN][entry.unique_id] = device @@ -111,17 +117,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - - device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] - +async def _async_stop_device(hass: HomeAssistant, device: ONVIFDevice) -> None: + """Stop the ONVIF device.""" if device.capabilities.events and device.events.started: try: await device.events.async_stop() except (TimeoutError, ONVIFError, Fault, aiohttp.ClientError, TransportError): LOGGER.warning("Error while stopping events: %s", device.name) + await device.device.close() + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + device: ONVIFDevice = hass.data[DOMAIN][entry.unique_id] + await _async_stop_device(hass, device) return await hass.config_entries.async_unload_platforms(entry, device.platforms) From 9f45801409644a60d7011578c43c3345ba3952c0 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:03:27 -0700 Subject: [PATCH 0731/1118] Remove advanced mode from group `all` option. (#149626) --- homeassistant/components/group/config_flow.py | 4 +--- tests/components/group/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 5e36087e9e4..152e629be2e 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -141,9 +141,7 @@ async def light_switch_options_schema( """Generate options schema.""" return (await basic_group_options_schema(domain, handler)).extend( { - vol.Required( - CONF_ALL, default=False, description={"advanced": True} - ): selector.BooleanSelector(), + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), } ) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 30adae2fd2a..322e6ebdad0 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -315,11 +315,11 @@ async def test_options( ("group_type", "extra_options", "extra_options_after", "advanced"), [ ("light", {"all": False}, {"all": False}, False), - ("light", {"all": True}, {"all": True}, False), + ("light", {"all": True}, {"all": False}, False), ("light", {"all": False}, {"all": False}, True), ("light", {"all": True}, {"all": False}, True), ("switch", {"all": False}, {"all": False}, False), - ("switch", {"all": True}, {"all": True}, False), + ("switch", {"all": True}, {"all": False}, False), ("switch", {"all": False}, {"all": False}, True), ("switch", {"all": True}, {"all": False}, True), ], From c4c4463c63ffe11f25c8d90a06d93c15c7190132 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 29 Jul 2025 23:00:49 +0300 Subject: [PATCH 0732/1118] Update IQS for Alexa Devices (#149639) --- homeassistant/components/alexa_devices/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 47ff53dd04e..95433655212 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -48,7 +48,7 @@ rules: comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration docs-data-update: done docs-examples: done - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done From 62713b137162553b64d0a9255aa6b6c5ce0dfc4d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 29 Jul 2025 22:32:32 +0200 Subject: [PATCH 0733/1118] Update pyblu to 2.0.4 (#149589) --- homeassistant/components/bluesound/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index caf5cc7541d..54fb061676d 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.1"], + "requirements": ["pyblu==2.0.4"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index abb0e9ded9c..91cfb6e0236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1870,7 +1870,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.1 +pyblu==2.0.4 # homeassistant.components.neato pybotvac==0.0.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c544ff5a88..2ba7f3af443 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.1 +pyblu==2.0.4 # homeassistant.components.neato pybotvac==0.0.28 From 52ee5d53ee68ac5cafe6d98fa3e602d58924057d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:27:43 +0200 Subject: [PATCH 0734/1118] bump pyenphase to 2.2.3 (#149641) --- 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 320179bf2df..e337dac74e0 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.2"], + "requirements": ["pyenphase==2.2.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 91cfb6e0236..835128628ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1966,7 +1966,7 @@ pyegps==0.2.5 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.2 +pyenphase==2.2.3 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ba7f3af443..56eaec04d3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1641,7 +1641,7 @@ pyegps==0.2.5 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.2 +pyenphase==2.2.3 # homeassistant.components.everlights pyeverlights==0.1.0 From 73e578b168b5c5d9de952db9bc9f2f4a36993e69 Mon Sep 17 00:00:00 2001 From: hypnosiss <11396064+hypnosiss@users.noreply.github.com> Date: Tue, 29 Jul 2025 23:29:53 +0200 Subject: [PATCH 0735/1118] Bump pymysensors library version (#149632) --- homeassistant/components/mysensors/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index a4b802f001c..f9cabda90b7 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mysensors", "iot_class": "local_push", "loggers": ["mysensors"], - "requirements": ["pymysensors==0.25.0"] + "requirements": ["pymysensors==0.26.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 835128628ae..1e2f4ec081e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2164,7 +2164,7 @@ pymonoprice==0.4 pymsteams==0.1.12 # homeassistant.components.mysensors -pymysensors==0.25.0 +pymysensors==0.26.0 # homeassistant.components.iron_os pynecil==4.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56eaec04d3e..d42453d82fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1800,7 +1800,7 @@ pymodbus==3.9.2 pymonoprice==0.4 # homeassistant.components.mysensors -pymysensors==0.25.0 +pymysensors==0.26.0 # homeassistant.components.iron_os pynecil==4.1.1 From 45ae34cc0e7822db732d5aef19c9a42d31221774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 30 Jul 2025 00:23:03 +0200 Subject: [PATCH 0736/1118] Strip leading and trailing whitespace in program names in miele action response (#149643) --- homeassistant/components/miele/services.py | 2 +- tests/components/miele/fixtures/programs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 6d4dc77dd36..3d73c021b3d 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -126,7 +126,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse: "programs": [ { "program_id": item["programId"], - "program": item["program"], + "program": item["program"].strip(), "parameters": ( { "temperature": ( diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json index 06eddc5fedc..ce2348f61de 100644 --- a/tests/components/miele/fixtures/programs.json +++ b/tests/components/miele/fixtures/programs.json @@ -11,7 +11,7 @@ }, { "programId": 123, - "program": "Dark garments / Denim", + "program": "Dark garments / Denim ", "parameters": {} }, { From 0dd1e0cabbd8e5a8282b19caab3dd32512cc8ddb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Jul 2025 09:06:15 +0200 Subject: [PATCH 0737/1118] Suppress exception stack trace when writing MQTT entity state if a ValueError occured (#149583) --- homeassistant/components/mqtt/models.py | 9 +++++++++ tests/components/mqtt/test_init.py | 27 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 8a42797b0f2..4cc0424195a 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -364,6 +364,15 @@ class EntityTopicState: entity_id, entity = self.subscribe_calls.popitem() try: entity.async_write_ha_state() + except ValueError as exc: + _LOGGER.error( + "Value error while updating state of %s, topic: " + "'%s' with payload: %s: %s", + entity_id, + msg.topic, + msg.payload, + exc, + ) except Exception: _LOGGER.exception( "Exception raised while updating state of %s, topic: " diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index f789d7f3be1..1aeb9843b54 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -604,6 +604,23 @@ def test_entity_device_info_schema() -> None: ) +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + ( + ValueError("Invalid value for sensor"), + "Value error while updating " + "state of sensor.test_sensor, topic: 'test/state' " + "with payload: b'payload causing errors'", + ), + ( + TypeError("Invalid value for sensor"), + "Exception raised while updating " + "state of sensor.test_sensor, topic: 'test/state' " + "with payload: b'payload causing errors'", + ), + ], +) @pytest.mark.parametrize( "hass_config", [ @@ -625,6 +642,8 @@ async def test_handle_logging_on_writing_the_entity_state( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + side_effect: Exception, + error_message: str, ) -> None: """Test on log handling when an error occurs writing the state.""" await mqtt_mock_entry() @@ -637,7 +656,7 @@ async def test_handle_logging_on_writing_the_entity_state( assert state.state == "initial_state" with patch( "homeassistant.helpers.entity.Entity.async_write_ha_state", - side_effect=ValueError("Invalid value for sensor"), + side_effect=side_effect, ): async_fire_mqtt_message(hass, "test/state", b"payload causing errors") await hass.async_block_till_done() @@ -645,11 +664,7 @@ async def test_handle_logging_on_writing_the_entity_state( assert state is not None assert state.state == "initial_state" assert "Invalid value for sensor" in caplog.text - assert ( - "Exception raised while updating " - "state of sensor.test_sensor, topic: 'test/state' " - "with payload: b'payload causing errors'" in caplog.text - ) + assert error_message in caplog.text async def test_receiving_non_utf8_message_gets_logged( From 2ee82e1d6f548d04e5dcfd768b42ce5189e2e9a3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 30 Jul 2025 09:24:16 +0200 Subject: [PATCH 0738/1118] Remove battery attribute from Ecovacs vacuums (#149581) --- homeassistant/components/ecovacs/vacuum.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index d432410c8c5..86a30558375 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device -from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent +from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent from deebot_client.models import CleanAction, CleanMode, Room, State import sucks @@ -216,7 +216,6 @@ class EcovacsVacuum( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATE @@ -243,10 +242,6 @@ class EcovacsVacuum( """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() - async def on_battery(event: BatteryEvent) -> None: - self._attr_battery_level = event.value - self.async_write_ha_state() - async def on_rooms(event: RoomsEvent) -> None: self._rooms = event.rooms self.async_write_ha_state() @@ -255,7 +250,6 @@ class EcovacsVacuum( self._attr_activity = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() - self._subscribe(self._capability.battery.event, on_battery) self._subscribe(self._capability.state.event, on_status) if self._capability.fan_speed: From f66e83f33ebe382696edc612c3580c2c3b156942 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 30 Jul 2025 09:54:00 +0200 Subject: [PATCH 0739/1118] Add dynamic encryption key support to the ESPHome integration (#148746) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/esphome/config_flow.py | 30 +- .../esphome/encryption_key_storage.py | 94 ++++ homeassistant/components/esphome/manager.py | 98 +++- tests/components/esphome/test_config_flow.py | 115 +++++ .../esphome/test_dynamic_encryption.py | 102 ++++ tests/components/esphome/test_manager.py | 484 +++++++++++++++++- 6 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/esphome/encryption_key_storage.py create mode 100644 tests/components/esphome/test_dynamic_encryption.py diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 75408246e78..dc0e9b8e1b1 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -51,6 +51,7 @@ from .const import ( DOMAIN, ) from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info +from .encryption_key_storage import async_get_encryption_key_storage from .entry_data import ESPHomeConfigEntry from .manager import async_replace_device @@ -159,7 +160,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reauthorization flow.""" errors = {} - if await self._retrieve_encryption_key_from_dashboard(): + if ( + await self._retrieve_encryption_key_from_storage() + or await self._retrieve_encryption_key_from_dashboard() + ): error = await self.fetch_device_info() if error is None: return await self._async_authenticate_or_add() @@ -226,9 +230,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): response = await self.fetch_device_info() self._noise_psk = None + # Try to retrieve an existing key from dashboard or storage. if ( self._device_name and await self._retrieve_encryption_key_from_dashboard() + ) or ( + self._device_mac and await self._retrieve_encryption_key_from_storage() ): response = await self.fetch_device_info() @@ -284,6 +291,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._name = discovery_info.properties.get("friendly_name", device_name) self._host = discovery_info.host self._port = discovery_info.port + self._device_mac = mac_address self._noise_required = bool(discovery_info.properties.get("api_encryption")) # Check if already configured @@ -772,6 +780,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._noise_psk = noise_psk return True + async def _retrieve_encryption_key_from_storage(self) -> bool: + """Try to retrieve the encryption key from storage. + + Return boolean if a key was retrieved. + """ + # Try to get MAC address from current flow state or reauth entry + mac_address = self._device_mac + if mac_address is None and self._reauth_entry is not None: + # In reauth flow, get MAC from the existing entry's unique_id + mac_address = self._reauth_entry.unique_id + + assert mac_address is not None + + storage = await async_get_encryption_key_storage(self.hass) + if stored_key := await storage.async_get_key(mac_address): + self._noise_psk = stored_key + return True + + return False + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/esphome/encryption_key_storage.py b/homeassistant/components/esphome/encryption_key_storage.py new file mode 100644 index 00000000000..e4b5ef41c2e --- /dev/null +++ b/homeassistant/components/esphome/encryption_key_storage.py @@ -0,0 +1,94 @@ +"""Encryption key storage for ESPHome devices.""" + +from __future__ import annotations + +import logging +from typing import TypedDict + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey + +_LOGGER = logging.getLogger(__name__) + +ENCRYPTION_KEY_STORAGE_VERSION = 1 +ENCRYPTION_KEY_STORAGE_KEY = "esphome.encryption_keys" + + +class EncryptionKeyData(TypedDict): + """Encryption key storage data.""" + + keys: dict[str, str] # MAC address -> base64 encoded key + + +KEY_ENCRYPTION_STORAGE: HassKey[ESPHomeEncryptionKeyStorage] = HassKey( + "esphome_encryption_key_storage" +) + + +class ESPHomeEncryptionKeyStorage: + """Storage for ESPHome encryption keys.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the encryption key storage.""" + self.hass = hass + self._store = Store[EncryptionKeyData]( + hass, + ENCRYPTION_KEY_STORAGE_VERSION, + ENCRYPTION_KEY_STORAGE_KEY, + encoder=JSONEncoder, + ) + self._data: EncryptionKeyData | None = None + + async def async_load(self) -> None: + """Load encryption keys from storage.""" + if self._data is None: + data = await self._store.async_load() + self._data = data or {"keys": {}} + + async def async_save(self) -> None: + """Save encryption keys to storage.""" + if self._data is not None: + await self._store.async_save(self._data) + + async def async_get_key(self, mac_address: str) -> str | None: + """Get encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + return self._data["keys"].get(mac_address.lower()) + + async def async_store_key(self, mac_address: str, key: str) -> None: + """Store encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + self._data["keys"][mac_address.lower()] = key + await self.async_save() + _LOGGER.debug( + "Stored encryption key for device with MAC %s", + mac_address, + ) + + async def async_remove_key(self, mac_address: str) -> None: + """Remove encryption key for a MAC address.""" + await self.async_load() + assert self._data is not None + lower_mac_address = mac_address.lower() + if lower_mac_address in self._data["keys"]: + del self._data["keys"][lower_mac_address] + await self.async_save() + _LOGGER.debug( + "Removed encryption key for device with MAC %s", + mac_address, + ) + + +@singleton(KEY_ENCRYPTION_STORAGE, async_=True) +async def async_get_encryption_key_storage( + hass: HomeAssistant, +) -> ESPHomeEncryptionKeyStorage: + """Get the encryption key storage instance.""" + storage = ESPHomeEncryptionKeyStorage(hass) + await storage.async_load() + return storage diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 5e9e11171af..4d5de77b1e0 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import base64 from functools import partial import logging +import secrets from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -68,6 +70,7 @@ from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, + CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_URL, @@ -78,6 +81,7 @@ from .const import ( ) from .dashboard import async_get_dashboard from .domain_data import DomainData +from .encryption_key_storage import async_get_encryption_key_storage # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData @@ -85,9 +89,7 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" if TYPE_CHECKING: - from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] - SubscribeLogsResponse, - ) + from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001 _LOGGER = logging.getLogger(__name__) @@ -515,6 +517,8 @@ class ESPHomeManager: assert api_version is not None, "API version must be set" entry_data.async_on_connect(device_info, api_version) + await self._handle_dynamic_encryption_key(device_info) + if device_info.name: reconnect_logic.name = device_info.name @@ -618,6 +622,7 @@ class ESPHomeManager: ), ): return + if isinstance(err, InvalidEncryptionKeyAPIError): if ( (received_name := err.received_name) @@ -648,6 +653,93 @@ class ESPHomeManager: return self.entry.async_start_reauth(self.hass) + async def _handle_dynamic_encryption_key( + self, device_info: EsphomeDeviceInfo + ) -> None: + """Handle dynamic encryption keys. + + If a device reports it supports encryption, but we connected without a key, + we need to generate and store one. + """ + noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK) + if noise_psk: + # we're already connected with a noise PSK - nothing to do + return + + if not device_info.api_encryption_supported: + # device does not support encryption - nothing to do + return + + # Connected to device without key and the device supports encryption + storage = await async_get_encryption_key_storage(self.hass) + + # First check if we have a key in storage for this device + from_storage: bool = False + if self.entry.unique_id and ( + stored_key := await storage.async_get_key(self.entry.unique_id) + ): + _LOGGER.debug( + "Retrieved encryption key from storage for device %s", + self.entry.unique_id, + ) + # Use the stored key + new_key = stored_key.encode() + new_key_str = stored_key + from_storage = True + else: + # No stored key found, generate a new one + _LOGGER.debug( + "Generating new encryption key for device %s", self.entry.unique_id + ) + new_key = base64.b64encode(secrets.token_bytes(32)) + new_key_str = new_key.decode() + + try: + # Store the key on the device using the existing connection + result = await self.cli.noise_encryption_set_key(new_key) + except APIConnectionError as ex: + _LOGGER.error( + "Connection error while storing encryption key for device %s (%s): %s", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ex, + ) + return + else: + if not result: + _LOGGER.error( + "Failed to set dynamic encryption key on device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + return + + # Key stored successfully on device + assert self.entry.unique_id is not None + + # Only store in storage if it was newly generated + if not from_storage: + await storage.async_store_key(self.entry.unique_id, new_key_str) + + # Always update config entry + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONF_NOISE_PSK: new_key_str}, + ) + + if from_storage: + _LOGGER.info( + "Set encryption key from storage on device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + else: + _LOGGER.info( + "Generated and stored encryption key for device %s (%s)", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ) + @callback def _async_handle_logging_changed(self, _event: Event) -> None: """Handle when the logging level changes.""" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 3f0148262e4..d76991a984c 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -27,6 +27,9 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) +from homeassistant.components.esphome.encryption_key_storage import ( + ENCRYPTION_KEY_STORAGE_KEY, +) from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -41,6 +44,118 @@ from .conftest import MockGenericDeviceEntryType from tests.common import MockConfigEntry + +async def test_retrieve_encryption_key_from_storage_with_device_mac( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test key successfully retrieved from storage.""" + + # Mock the encryption key storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {"11:22:33:44:55:aa": VALID_NOISE_PSK}}, + } + + mock_client.device_info.side_effect = [ + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test", "11:22:33:44:55:AA"), + DeviceInfo( + uses_password=False, + name="test", + mac_address="11:22:33:44:55:AA", + ), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: VALID_NOISE_PSK, + CONF_DEVICE_NAME: "test", + } + + assert mock_client.noise_psk == VALID_NOISE_PSK + + +async def test_reauth_fixed_from_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], +) -> None: + """Test reauth fixed automatically via storage.""" + + # Mock the encryption key storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {"11:22:33:44:55:aa": VALID_NOISE_PSK}}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.ABORT, result + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +async def test_retrieve_encryption_key_from_storage_no_key_found( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test _retrieve_encryption_key_from_storage when no key is found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "reauth_confirm" + assert CONF_NOISE_PSK not in entry.data + + INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" WRONG_NOISE_PSK = "GP+ciK+nVfTQ/gcz6uOdS+oKEdJgesU+jeu8Ssj2how=" diff --git a/tests/components/esphome/test_dynamic_encryption.py b/tests/components/esphome/test_dynamic_encryption.py new file mode 100644 index 00000000000..cbdcc35aea2 --- /dev/null +++ b/tests/components/esphome/test_dynamic_encryption.py @@ -0,0 +1,102 @@ +"""Tests for ESPHome dynamic encryption key generation.""" + +from __future__ import annotations + +import base64 + +from homeassistant.components.esphome.encryption_key_storage import ( + ESPHomeEncryptionKeyStorage, + async_get_encryption_key_storage, +) +from homeassistant.core import HomeAssistant + + +async def test_dynamic_encryption_key_generation_mock(hass: HomeAssistant) -> None: + """Test that encryption key generation works with mocked storage.""" + storage = await async_get_encryption_key_storage(hass) + + # Store a key + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"test_key_32_bytes_long_exactly!").decode() + + await storage.async_store_key(mac_address, test_key) + + # Retrieve a key + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == test_key + + +async def test_encryption_key_storage_remove_key(hass: HomeAssistant) -> None: + """Test ESPHomeEncryptionKeyStorage async_remove_key method.""" + # Create storage instance + storage = ESPHomeEncryptionKeyStorage(hass) + + # Test removing a key that exists + mac_address = "11:22:33:44:55:aa" + test_key = "test_encryption_key_32_bytes_long" + + # First store a key + await storage.async_store_key(mac_address, test_key) + + # Verify key exists + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == test_key + + # Remove the key + await storage.async_remove_key(mac_address) + + # Verify key no longer exists + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key is None + + # Test removing a key that doesn't exist (should not raise an error) + non_existent_mac = "aa:bb:cc:dd:ee:ff" + await storage.async_remove_key(non_existent_mac) # Should not raise + + # Test case insensitive removal + upper_mac = "22:33:44:55:66:77" + await storage.async_store_key(upper_mac, test_key) + + # Remove using lowercase MAC address + await storage.async_remove_key(upper_mac.lower()) + + # Verify key was removed + retrieved_key = await storage.async_get_key(upper_mac) + assert retrieved_key is None + + +async def test_encryption_key_basic_storage( + hass: HomeAssistant, +) -> None: + """Test basic encryption key storage functionality.""" + storage = await async_get_encryption_key_storage(hass) + mac_address = "11:22:33:44:55:aa" + key = "test_encryption_key_32_bytes_long" + + # Store key + await storage.async_store_key(mac_address, key) + + # Retrieve key + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == key + + +async def test_retrieve_key_from_storage( + hass: HomeAssistant, +) -> None: + """Test config flow can retrieve encryption key from storage for new device.""" + # Test that the encryption key storage integration works with config flow + storage = await async_get_encryption_key_storage(hass) + mac_address = "11:22:33:44:55:aa" + stored_key = "test_encryption_key_32_bytes_long" + + # Store encryption key for a device + await storage.async_store_key(mac_address, stored_key) + + # Verify the key can be retrieved (simulating config flow behavior) + retrieved_key = await storage.async_get_key(mac_address) + assert retrieved_key == stored_key + + # Test case insensitive retrieval (since config flows might use different case) + retrieved_key_upper = await storage.async_get_key(mac_address.upper()) + assert retrieved_key_upper == stored_key diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 318ccde221f..8d2dd211869 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,8 +1,10 @@ """Test ESPHome manager.""" import asyncio +import base64 import logging -from unittest.mock import AsyncMock, Mock, call +from typing import Any +from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, @@ -27,11 +29,15 @@ from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, + CONF_NOISE_PSK, CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) +from homeassistant.components.esphome.encryption_key_storage import ( + ENCRYPTION_KEY_STORAGE_KEY, +) from homeassistant.components.esphome.manager import DEVICE_CONFLICT_ISSUE_FORMAT from homeassistant.components.tag import DOMAIN as TAG_DOMAIN from homeassistant.const import ( @@ -1788,3 +1794,479 @@ async def test_sub_device_references_main_device_area( ) assert sub_device_3 is not None assert sub_device_3.suggested_area == "Bedroom" + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_generation( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test that a device without a key in storage gets a new one generated.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify the key was generated and set + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + + # Verify config entry was updated + assert entry.data[CONF_NOISE_PSK] == expected_key + + +async def test_manager_retrieves_key_from_storage_on_reconnect( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test that manager retrieves encryption key from storage during reconnect.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + # Set up storage with existing key + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + # Create entry without noise PSK (will be loaded from storage) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key retrieval from storage + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify noise_encryption_set_key was called with the stored key + mock_client.noise_encryption_set_key.assert_called_once_with(test_key.encode()) + + # Verify config entry was updated with key from storage + assert entry.data[CONF_NOISE_PSK] == test_key + + +async def test_manager_handle_dynamic_encryption_key_guard_clauses( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test _handle_dynamic_encryption_key guard clauses and early returns.""" + # Test guard clause - no unique_id + entry_no_id = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=None, # No unique ID - should not generate key + ) + entry_no_id.add_to_hass(hass) + + # Set up device without unique ID + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry_no_id, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": "11:22:33:44:55:aa", + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # noise_encryption_set_key should not be called when no unique_id + mock_client.noise_encryption_set_key = AsyncMock() + await device.mock_disconnect(True) + await device.mock_connect() + + mock_client.noise_encryption_set_key.assert_not_called() + + +async def test_manager_handle_dynamic_encryption_key_edge_cases( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test _handle_dynamic_encryption_key edge cases for better coverage.""" + mac_address = "11:22:33:44:55:aa" + + # Test device without encryption support + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Set up device without encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": False, # No encryption support + }, + ) + + # noise_encryption_set_key should not be called when encryption not supported + mock_client.noise_encryption_set_key = AsyncMock() + await device.mock_disconnect(True) + await device.mock_connect() + + mock_client.noise_encryption_set_key.assert_not_called() + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_dynamic_encryption_key_generation_flow( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test the complete dynamic encryption key generation flow.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify the complete flow + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + assert entry.data[CONF_NOISE_PSK] == expected_key + + # Verify key was stored in hass_storage + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_no_existing_key( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when no existing key is found.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation flow + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once() + + # Verify config entry was updated + assert entry.data[CONF_NOISE_PSK] == expected_key + + # Verify key was stored + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_device_set_key_fails( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when noise_encryption_set_key returns False.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods - set_key returns False + mock_client.noise_encryption_set_key = AsyncMock(return_value=False) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Reset mocks since initial connection already happened + mock_token_bytes.reset_mock() + mock_client.noise_encryption_set_key.reset_mock() + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation was attempted with the expected key + mock_token_bytes.assert_called_once_with(32) + mock_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + + # Verify config entry was NOT updated since set_key failed + assert CONF_NOISE_PSK not in entry.data + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_manager_handle_dynamic_encryption_key_connection_error( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test _handle_dynamic_encryption_key when noise_encryption_set_key raises APIConnectionError.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + + # Initialize empty storage + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": { + "keys": {} # No existing keys + }, + } + + # Create entry without noise PSK + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + + # Mock the client methods - set_key raises APIConnectionError + mock_client.noise_encryption_set_key = AsyncMock( + side_effect=APIConnectionError("Connection failed") + ) + + # Set up device with encryption support + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2023.12.0", + "api_encryption_supported": True, + }, + ) + + # Force reconnect to trigger key generation + await device.mock_disconnect(True) + await device.mock_connect() + + # Verify key generation was attempted twice (once during setup, once during reconnect) + # This is expected because the first attempt failed with connection error + assert mock_token_bytes.call_count == 2 + mock_token_bytes.assert_called_with(32) + assert mock_client.noise_encryption_set_key.call_count == 2 + + # Verify config entry was NOT updated since connection error occurred + assert CONF_NOISE_PSK not in entry.data + + # Verify key was NOT stored due to connection error + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] From 6f8214bbb47364d376cb45794248d11ea307bc74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 30 Jul 2025 10:22:35 +0200 Subject: [PATCH 0740/1118] Fix spelling mistakes in abort message of `leaone` (#149653) --- homeassistant/components/leaone/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json index bb684941147..53332ce2fec 100644 --- a/homeassistant/components/leaone/strings.json +++ b/homeassistant/components/leaone/strings.json @@ -13,7 +13,7 @@ } }, "abort": { - "no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", + "no_devices_found": "No supported LeaOne devices found in range. If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } From 6b641411a01e036a38e77c3361cbc5dec922753e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:33:09 +0200 Subject: [PATCH 0741/1118] Bump github/codeql-action from 3.29.4 to 3.29.5 (#149648) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cc6014b38b0..c5dcf19ce6e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.4 + uses: github/codeql-action/init@v3.29.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.4 + uses: github/codeql-action/analyze@v3.29.5 with: category: "/language:python" From 8e9e304608e3a58d61e8d99164847e193c38a3af Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:38:42 +0200 Subject: [PATCH 0742/1118] Update lxml to 6.0.0 (#149640) --- homeassistant/components/scrape/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 28e08372d68..8b9d7ddf37e 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"] + "requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e2f4ec081e..eafa0b0d47f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1391,7 +1391,7 @@ lupupy==0.3.2 lw12==0.9.2 # homeassistant.components.scrape -lxml==5.3.0 +lxml==6.0.0 # homeassistant.components.matrix matrix-nio==0.25.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d42453d82fe..b4ed33e539b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1189,7 +1189,7 @@ luftdaten==0.7.4 lupupy==0.3.2 # homeassistant.components.scrape -lxml==5.3.0 +lxml==6.0.0 # homeassistant.components.matrix matrix-nio==0.25.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 9c3f60a827c..99a1c255e60 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -30,6 +30,7 @@ PACKAGE_CHECK_VERSION_RANGE = { "bleak": "SemVer", "grpcio": "SemVer", "httpx": "SemVer", + "lxml": "SemVer", "mashumaro": "SemVer", "numpy": "SemVer", "pandas": "SemVer", From bb6bcfdd0158a03923751b7e73a21ce132ed75ad Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 30 Jul 2025 11:07:41 +0200 Subject: [PATCH 0743/1118] Add Z-Wave controller firmware updates (#149623) --- homeassistant/components/zwave_js/__init__.py | 17 +- homeassistant/components/zwave_js/update.py | 128 +++- tests/components/zwave_js/test_update.py | 705 ++++++++++++------ 3 files changed, 580 insertions(+), 270 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index d754419c94c..360969e83d4 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -147,6 +147,7 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) +MIN_CONTROLLER_FIRMWARE_SDK_VERSION = AwesomeVersion("6.50.0") PLATFORMS = [ Platform.BINARY_SENSOR, @@ -799,11 +800,19 @@ class NodeEvents: node.on("notification", self.async_on_notification) ) - # Create a firmware update entity for each non-controller device that + # Create a firmware update entity for each device that # supports firmware updates - if not node.is_controller_node and any( - cc.id == CommandClass.FIRMWARE_UPDATE_MD.value - for cc in node.command_classes + controller = self.controller_events.driver_events.driver.controller + if ( + not (is_controller_node := node.is_controller_node) + and any( + cc.id == CommandClass.FIRMWARE_UPDATE_MD.value + for cc in node.command_classes + ) + ) or ( + is_controller_node + and (sdk_version := controller.sdk_version) is not None + and sdk_version >= MIN_CONTROLLER_FIRMWARE_SDK_VERSION ): async_dispatcher_send( self.hass, diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 89fb4dd4aba..42a4b4cf6dd 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -4,26 +4,28 @@ from __future__ import annotations import asyncio from collections import Counter -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Final +from typing import Any, Final, cast from awesomeversion import AwesomeVersion from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver -from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.node.firmware import ( - NodeFirmwareUpdateInfo, - NodeFirmwareUpdateProgress, - NodeFirmwareUpdateResult, +from zwave_js_server.model.firmware import ( + FirmwareUpdateInfo, + FirmwareUpdateProgress, + FirmwareUpdateResult, ) +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo from homeassistant.components.update import ( ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, + UpdateEntityDescription, UpdateEntityFeature, ) from homeassistant.const import EntityCategory @@ -45,11 +47,54 @@ UPDATE_DELAY_INTERVAL = 5 # In minutes ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" +@dataclass(frozen=True, kw_only=True) +class ZWaveUpdateEntityDescription(UpdateEntityDescription): + """Class describing Z-Wave update entity.""" + + install_method: Callable[ + [ZWaveFirmwareUpdateEntity, FirmwareUpdateInfo], + Awaitable[FirmwareUpdateResult], + ] + progress_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]] + finished_method: Callable[[ZWaveFirmwareUpdateEntity], Callable[[], None]] + + +CONTROLLER_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription( + key="controller_firmware_update", + install_method=( + lambda entity, firmware_update_info: entity.driver.async_firmware_update_otw( + update_info=firmware_update_info + ) + ), + progress_method=lambda entity: entity.driver.on( + "firmware update progress", entity.update_progress + ), + finished_method=lambda entity: entity.driver.on( + "firmware update finished", entity.update_finished + ), +) +NODE_UPDATE_ENTITY_DESCRIPTION = ZWaveUpdateEntityDescription( + key="node_firmware_update", + install_method=( + lambda entity, + firmware_update_info: entity.driver.controller.async_firmware_update_ota( + entity.node, cast(NodeFirmwareUpdateInfo, firmware_update_info) + ) + ), + progress_method=lambda entity: entity.node.on( + "firmware update progress", entity.update_progress + ), + finished_method=lambda entity: entity.node.on( + "firmware update finished", entity.update_finished + ), +) + + @dataclass -class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): +class ZWaveFirmwareUpdateExtraStoredData(ExtraStoredData): """Extra stored data for Z-Wave node firmware update entity.""" - latest_version_firmware: NodeFirmwareUpdateInfo | None + latest_version_firmware: FirmwareUpdateInfo | None def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" @@ -60,7 +105,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): } @classmethod - def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: + def from_dict(cls, data: dict[str, Any]) -> ZWaveFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" # If there was no firmware info stored, or if it's stale info, we don't restore # anything. @@ -70,7 +115,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): ): return cls(None) - return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) + return cls(FirmwareUpdateInfo.from_dict(firmware_dict)) async def async_setup_entry( @@ -92,7 +137,23 @@ async def async_setup_entry( delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)]) + if node.is_controller_node: + # If the node is a controller, we create a controller firmware update entity + entity = ZWaveFirmwareUpdateEntity( + driver, + node, + delay=delay, + entity_description=CONTROLLER_UPDATE_ENTITY_DESCRIPTION, + ) + else: + # If the node is not a controller, we create a node firmware update entity + entity = ZWaveFirmwareUpdateEntity( + driver, + node, + delay=delay, + entity_description=NODE_UPDATE_ENTITY_DESCRIPTION, + ) + async_add_entities([entity]) config_entry.async_on_unload( async_dispatcher_connect( @@ -103,9 +164,12 @@ async def async_setup_entry( ) -class ZWaveNodeFirmwareUpdate(UpdateEntity): +class ZWaveFirmwareUpdateEntity(UpdateEntity): """Representation of a firmware update entity.""" + driver: Driver + entity_description: ZWaveUpdateEntityDescription + node: ZwaveNode _attr_entity_category = EntityCategory.CONFIG _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_supported_features = ( @@ -116,17 +180,24 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None: + def __init__( + self, + driver: Driver, + node: ZwaveNode, + delay: timedelta, + entity_description: ZWaveUpdateEntityDescription, + ) -> None: """Initialize a Z-Wave device firmware update entity.""" self.driver = driver + self.entity_description = entity_description self.node = node - self._latest_version_firmware: NodeFirmwareUpdateInfo | None = None + self._latest_version_firmware: FirmwareUpdateInfo | None = None self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None self._finished_unsub: Callable[[], None] | None = None self._finished_event = asyncio.Event() - self._result: NodeFirmwareUpdateResult | None = None + self._result: FirmwareUpdateResult | None = None self._delay: Final[timedelta] = delay # Entity class attributes @@ -138,9 +209,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_device_info = get_device_info(driver, node) @property - def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData: + def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData: """Return ZWave Node Firmware Update specific state data to be restored.""" - return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) + return ZWaveFirmwareUpdateExtraStoredData(self._latest_version_firmware) @callback def _update_on_status_change(self, _: dict[str, Any]) -> None: @@ -149,9 +220,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self.hass.async_create_task(self._async_update()) @callback - def _update_progress(self, event: dict[str, Any]) -> None: + def update_progress(self, event: dict[str, Any]) -> None: """Update install progress on event.""" - progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"] + progress: FirmwareUpdateProgress = event["firmware_update_progress"] if not self._latest_version_firmware: return self._attr_in_progress = True @@ -159,9 +230,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self.async_write_ha_state() @callback - def _update_finished(self, event: dict[str, Any]) -> None: + def update_finished(self, event: dict[str, Any]) -> None: """Update install progress on event.""" - result: NodeFirmwareUpdateResult = event["firmware_update_finished"] + result: FirmwareUpdateResult = event["firmware_update_finished"] self._result = result self._finished_event.set() @@ -266,15 +337,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_update_percentage = None self.async_write_ha_state() - self._progress_unsub = self.node.on( - "firmware update progress", self._update_progress - ) - self._finished_unsub = self.node.on( - "firmware update finished", self._update_finished - ) + self._progress_unsub = self.entity_description.progress_method(self) + self._finished_unsub = self.entity_description.finished_method(self) try: - await self.driver.controller.async_firmware_update_ota(self.node, firmware) + await self.entity_description.install_method(self, firmware) except BaseZwaveJSServerError as err: self._unsub_firmware_events_and_reset_progress() raise HomeAssistantError(err) from err @@ -342,8 +409,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): is not None and (extra_data := await self.async_get_last_extra_data()) and ( - latest_version_firmware - := ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + latest_version_firmware := ZWaveFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 17f154f4f78..fbe0a8bbea7 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -1,12 +1,17 @@ """Test the Z-Wave JS update entities.""" import asyncio +from copy import deepcopy from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest from zwave_js_server.event import Event from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.driver.firmware import DriverFirmwareUpdateStatus +from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateStatus from homeassistant.components.update import ( @@ -22,11 +27,16 @@ from homeassistant.components.update import ( SERVICE_SKIP, ) from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE -from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from tests.common import ( @@ -37,7 +47,8 @@ from tests.common import ( ) from tests.typing import WebSocketGenerator -UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +NODE_UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +CONTROLLER_UPDATE_ENTITY = "update.z_stick_gen5_usb_controller_firmware" LATEST_VERSION_FIRMWARE = { "version": "11.2.4", "changelog": "blah 2", @@ -112,26 +123,54 @@ FIRMWARE_UPDATES = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.UPDATE] + + +@pytest.fixture(name="controller_state", autouse=True) +def controller_state_fixture( + controller_state: dict[str, Any], +) -> dict[str, Any]: + """Load the controller state fixture data.""" + controller_state = deepcopy(controller_state) + # Set the minimum SDK version that supports firmware updates for controllers. + controller_state["controller"]["sdkVersion"] = "6.50.0" + return controller_state + + +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_states( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity states.""" ws_client = await hass_ws_client(hass) - assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + assert client.driver.controller.sdk_version == "6.50.0" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF client.async_send_command.return_value = {"updates": []} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -139,7 +178,7 @@ async def test_update_entity_states( { "id": 1, "type": "update/release_notes", - "entity_id": UPDATE_ENTITY, + "entity_id": entity_id, } ) result = await ws_client.receive_json() @@ -150,12 +189,12 @@ async def test_update_entity_states( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes assert not attrs[ATTR_AUTO_UPDATE] - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert attrs[ATTR_RELEASE_URL] is None @@ -165,7 +204,7 @@ async def test_update_entity_states( { "id": 2, "type": "update/release_notes", - "entity_id": UPDATE_ENTITY, + "entity_id": entity_id, } ) result = await ws_client.receive_json() @@ -176,7 +215,7 @@ async def test_update_entity_states( DOMAIN, SERVICE_REFRESH_VALUE, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) @@ -188,31 +227,21 @@ async def test_update_entity_states( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=3)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - # Assert a node firmware update entity is not created for the controller - driver = client.driver - node = driver.controller.nodes[1] - assert node.is_controller_node - assert ( - entity_registry.async_get_entity_id( - DOMAIN, - "sensor", - f"{get_valueless_base_unique_id(driver, node)}.firmware_update", - ) - is None - ) - - client.async_send_command.reset_mock() - +@pytest.mark.parametrize( + "entity_id", + [CONTROLLER_UPDATE_ENTITY, NODE_UPDATE_ENTITY], +) async def test_update_entity_install_raises( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, ) -> None: """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES @@ -228,7 +257,7 @@ async def test_update_entity_install_raises( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) @@ -236,9 +265,9 @@ async def test_update_entity_install_raises( async def test_update_entity_sleep( hass: HomeAssistant, - client, - zen_31, - integration, + client: MagicMock, + zen_31: Node, + integration: MockConfigEntry, ) -> None: """Test update occurs when device is asleep after it wakes up.""" event = Event( @@ -253,8 +282,15 @@ async def test_update_entity_sleep( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 + # Two nodes in total, the controller node and the zen_31 node. + # The zen_31 node is asleep, + # so we should only check for updates for the controller node. + assert client.async_send_command.call_count == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == 1 + + client.async_send_command.reset_mock() event = Event( "wake up", @@ -263,19 +299,20 @@ async def test_update_entity_sleep( zen_31.receive_event(event) await hass.async_block_till_done() - # Now that the node is up we can check for updates - assert len(client.async_send_command.call_args_list) > 0 - - args = client.async_send_command.call_args_list[0][0][0] + # Now that the zen_31 node is awake we can check for updates for it. + # The controller node has already been checked, + # so won't get another check now. + assert client.async_send_command.call_count == 1 + args = client.async_send_command.call_args[0][0] assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + assert args["nodeId"] == 94 async def test_update_entity_dead( hass: HomeAssistant, - client, - zen_31, - integration, + client: MagicMock, + zen_31: Node, + integration: MockConfigEntry, ) -> None: """Test update occurs even when device is dead.""" event = Event( @@ -290,18 +327,24 @@ async def test_update_entity_dead( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - # Checking for firmware updates should proceed even for dead nodes - assert len(client.async_send_command.call_args_list) > 0 + # Two nodes in total, the controller node and the zen_31 node. + # Checking for firmware updates should proceed even for dead nodes. + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] + ) - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + node_ids = (1, 94) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id async def test_update_entity_ha_not_running( hass: HomeAssistant, - client, - zen_31, + client: MagicMock, + zen_31: Node, hass_ws_client: WebSocketGenerator, ) -> None: """Test update occurs only after HA is running.""" @@ -314,81 +357,170 @@ async def test_update_entity_ha_not_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + client.async_send_command.reset_mock() + assert client.async_send_command.call_count == 0 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + assert client.async_send_command.call_count == 0 - # Update should be delayed by a day because HA is not running + # Update should be delayed by a day because Home Assistant is not running hass.set_state(CoreState.starting) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 4 + assert client.async_send_command.call_count == 0 hass.set_state(CoreState.running) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 5 - args = client.async_send_command.call_args_list[4][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + # Two nodes in total, the controller node and the zen_31 node. + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] + ) + + node_ids = (1, 94) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id async def test_update_entity_update_failure( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, ) -> None: """Test update entity update failed.""" - assert len(client.async_send_command.call_args_list) == 0 + assert client.async_send_command.call_count == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) - assert state - assert state.state == STATE_OFF - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert ( - args["nodeId"] - == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + entity_ids = (CONTROLLER_UPDATE_ENTITY, NODE_UPDATE_ENTITY) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + assert client.async_send_command.call_count == 2 + calls = sorted( + client.async_send_command.call_args_list, key=lambda call: call[0][0]["nodeId"] ) + node_ids = (1, 26) + for node_id, call in zip(node_ids, calls, strict=True): + args = call[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == node_id + +@pytest.mark.parametrize( + ( + "entity_id", + "installed_version", + "install_result", + "progress_event", + "finished_event", + ), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 255, "success": True}, + Event( + type="firmware update progress", + data={ + "source": "driver", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "driver", + "event": "firmware update finished", + "result": { + "status": DriverFirmwareUpdateStatus.OK, + "success": True, + }, + }, + ), + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": 254, "success": True, "reInterview": False}, + Event( + type="firmware update progress", + data={ + "source": "node", + "event": "firmware update progress", + "nodeId": 26, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "node", + "event": "firmware update finished", + "nodeId": 26, + "result": { + "status": NodeFirmwareUpdateStatus.OK_NO_RESTART, + "success": True, + "reInterview": False, + }, + }, + ), + ), + ], +) async def test_update_entity_progress( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + progress_event: Event, + finished_event: Event, ) -> None: """Test update entity progress.""" - node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES + driver = client.driver async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = { - "result": {"status": 2, "success": False, "reInterview": False} - } + client.async_send_command.return_value = {"result": install_result} # Test successful install call without a version install_task = hass.async_create_task( @@ -396,64 +528,36 @@ async def test_update_entity_progress( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - event = Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "progress": { - "currentFile": 1, - "totalFiles": 1, - "sentFragments": 1, - "totalFragments": 20, - "progress": 5.0, - }, - }, - ) - node.receive_event(event) + driver.receive_event(progress_event) + await asyncio.sleep(0.05) # Validate that the progress is updated - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] == 5 - event = Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "result": { - "status": NodeFirmwareUpdateStatus.OK_NO_RESTART, - "success": True, - "reInterview": False, - }, - }, - ) - - node.receive_event(event) + driver.receive_event(finished_event) await hass.async_block_till_done() # Validate that progress is reset and entity reflects new version - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is False @@ -465,31 +569,106 @@ async def test_update_entity_progress( await install_task +@pytest.mark.parametrize( + ( + "entity_id", + "installed_version", + "install_result", + "progress_event", + "finished_event", + ), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 0, "success": False}, + Event( + type="firmware update progress", + data={ + "source": "driver", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "driver", + "event": "firmware update finished", + "result": { + "status": DriverFirmwareUpdateStatus.ERROR_TIMEOUT, + "success": False, + }, + }, + ), + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": -1, "success": False, "reInterview": False}, + Event( + type="firmware update progress", + data={ + "source": "node", + "event": "firmware update progress", + "nodeId": 26, + "progress": { + "currentFile": 1, + "totalFiles": 1, + "sentFragments": 1, + "totalFragments": 20, + "progress": 5.0, + }, + }, + ), + Event( + type="firmware update finished", + data={ + "source": "node", + "event": "firmware update finished", + "nodeId": 26, + "result": { + "status": NodeFirmwareUpdateStatus.ERROR_TIMEOUT, + "success": False, + "reInterview": False, + }, + }, + ), + ), + ], +) async def test_update_entity_install_failed( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, caplog: pytest.LogCaptureFixture, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + progress_event: Event, + finished_event: Event, ) -> None: """Test update entity install returns error status.""" - node = climate_radio_thermostat_ct100_plus_different_endpoints + driver = client.driver client.async_send_command.return_value = FIRMWARE_UPDATES async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = { - "result": {"status": 2, "success": False, "reInterview": False} - } + client.async_send_command.return_value = {"result": install_result} # Test install call - we expect it to finish fail install_task = hass.async_create_task( @@ -497,63 +676,35 @@ async def test_update_entity_install_failed( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - event = Event( - type="firmware update progress", - data={ - "source": "node", - "event": "firmware update progress", - "nodeId": node.node_id, - "progress": { - "currentFile": 1, - "totalFiles": 1, - "sentFragments": 1, - "totalFragments": 20, - "progress": 5.0, - }, - }, - ) - node.receive_event(event) + driver.receive_event(progress_event) + await asyncio.sleep(0.05) # Validate that the progress is updated - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] == 5 - event = Event( - type="firmware update finished", - data={ - "source": "node", - "event": "firmware update finished", - "nodeId": node.node_id, - "result": { - "status": NodeFirmwareUpdateStatus.ERROR_TIMEOUT, - "success": False, - "reInterview": False, - }, - }, - ) - - node.receive_event(event) + driver.receive_event(finished_event) await hass.async_block_till_done() # Validate that progress is reset and entity reflects old version - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_LATEST_VERSION] == "11.2.4" assert state.state == STATE_ON @@ -562,21 +713,30 @@ async def test_update_entity_install_failed( await install_task +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_reload( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, - integration, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, + integration: MockConfigEntry, + entity_id: str, + installed_version: str, ) -> None: """Test update entity maintains state after reload.""" - assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + config_entry = integration + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF client.async_send_command.return_value = {"updates": []} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -585,12 +745,12 @@ async def test_update_entity_reload( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON attrs = state.attributes assert not attrs[ATTR_AUTO_UPDATE] - assert attrs[ATTR_INSTALLED_VERSION] == "10.7" + assert attrs[ATTR_INSTALLED_VERSION] == installed_version assert attrs[ATTR_IN_PROGRESS] is False assert attrs[ATTR_UPDATE_PERCENTAGE] is None assert attrs[ATTR_LATEST_VERSION] == "11.2.4" @@ -600,24 +760,24 @@ async def test_update_entity_reload( UPDATE_DOMAIN, SERVICE_SKIP, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" - await hass.config_entries.async_reload(integration.entry_id) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() # Trigger another update and make sure the skipped version is still skipped async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=4)) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" @@ -625,9 +785,9 @@ async def test_update_entity_reload( async def test_update_entity_delay( hass: HomeAssistant, - client, - ge_in_wall_dimmer_switch, - zen_31, + client: MagicMock, + ge_in_wall_dimmer_switch: Node, + zen_31: Node, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, ) -> None: @@ -641,12 +801,13 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 6 + client.async_send_command.reset_mock() + assert client.async_send_command.call_count == 0 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 6 + assert client.async_send_command.call_count == 0 update_interval = timedelta(minutes=5) freezer.tick(update_interval) @@ -655,8 +816,8 @@ async def test_update_entity_delay( nodes: set[int] = set() - assert len(client.async_send_command.call_args_list) == 7 - args = client.async_send_command.call_args_list[6][0][0] + assert client.async_send_command.call_count == 1 + args = client.async_send_command.call_args[0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) @@ -664,30 +825,45 @@ async def test_update_entity_delay( async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 8 - args = client.async_send_command.call_args_list[7][0][0] + assert client.async_send_command.call_count == 2 + args = client.async_send_command.call_args[0][0] assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) - assert len(nodes) == 2 - assert nodes == {ge_in_wall_dimmer_switch.node_id, zen_31.node_id} + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert client.async_send_command.call_count == 3 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.get_available_firmware_updates" + nodes.add(args["nodeId"]) + + assert len(nodes) == 3 + assert nodes == {1, ge_in_wall_dimmer_switch.node_id, zen_31.node_id} +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_partial_restore_data( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity with partial restore data resets state.""" mock_restore_cache( hass, [ State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: "11.2.4", }, @@ -699,16 +875,22 @@ async def test_update_entity_partial_restore_data( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_partial_restore_data_2( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test second scenario where update entity has partial restore data.""" mock_restore_cache_with_extra_data( @@ -716,10 +898,10 @@ async def test_update_entity_partial_restore_data_2( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_ON, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "10.8", ATTR_SKIPPED_VERSION: None, }, @@ -733,18 +915,24 @@ async def test_update_entity_partial_restore_data_2( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] is None +@pytest.mark.parametrize( + ("entity_id", "installed_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2"), (NODE_UPDATE_ENTITY, "10.7")], +) async def test_update_entity_full_restore_data_skipped_version( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, ) -> None: """Test update entity with full restore data (skipped version) restores state.""" mock_restore_cache_with_extra_data( @@ -752,10 +940,10 @@ async def test_update_entity_full_restore_data_skipped_version( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: "11.2.4", }, @@ -769,18 +957,44 @@ async def test_update_entity_full_restore_data_skipped_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" +@pytest.mark.parametrize( + ("entity_id", "installed_version", "install_result", "install_command_params"), + [ + ( + CONTROLLER_UPDATE_ENTITY, + "1.2", + {"status": 255, "success": True}, + { + "command": "driver.firmware_update_otw", + }, + ), + ( + NODE_UPDATE_ENTITY, + "10.7", + {"status": 255, "success": True, "reInterview": False}, + { + "command": "controller.firmware_update_ota", + "nodeId": 26, + }, + ), + ], +) async def test_update_entity_full_restore_data_update_available( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + install_result: dict[str, Any], + install_command_params: dict[str, Any], ) -> None: """Test update entity with full restore data (update available) restores state.""" mock_restore_cache_with_extra_data( @@ -788,10 +1002,10 @@ async def test_update_entity_full_restore_data_update_available( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: "11.2.4", ATTR_SKIPPED_VERSION: None, }, @@ -805,15 +1019,14 @@ async def test_update_entity_full_restore_data_update_available( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" - client.async_send_command.return_value = { - "result": {"status": 255, "success": True, "reInterview": False} - } + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"result": install_result} # Test successful install call without a version install_task = hass.async_create_task( @@ -821,25 +1034,24 @@ async def test_update_entity_full_restore_data_update_available( UPDATE_DOMAIN, SERVICE_INSTALL, { - ATTR_ENTITY_ID: UPDATE_ENTITY, + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) ) # Sleep so that task starts - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True assert attrs[ATTR_UPDATE_PERCENTAGE] is None - assert len(client.async_send_command.call_args_list) == 5 - assert client.async_send_command.call_args_list[4][0][0] == { - "command": "controller.firmware_update_ota", - "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + assert client.async_send_command.call_count == 1 + assert client.async_send_command.call_args[0][0] == { + **install_command_params, "updateInfo": { "version": "11.2.4", "changelog": "blah 2", @@ -862,11 +1074,18 @@ async def test_update_entity_full_restore_data_update_available( install_task.cancel() +@pytest.mark.parametrize( + ("entity_id", "installed_version", "latest_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2", "1.2"), (NODE_UPDATE_ENTITY, "10.7", "10.7")], +) async def test_update_entity_full_restore_data_no_update_available( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + latest_version: str, ) -> None: """Test entity with full restore data (no update available) restores state.""" mock_restore_cache_with_extra_data( @@ -874,11 +1093,11 @@ async def test_update_entity_full_restore_data_no_update_available( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", - ATTR_LATEST_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, + ATTR_LATEST_VERSION: latest_version, ATTR_SKIPPED_VERSION: None, }, ), @@ -891,18 +1110,25 @@ async def test_update_entity_full_restore_data_no_update_available( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None - assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + assert state.attributes[ATTR_LATEST_VERSION] == latest_version +@pytest.mark.parametrize( + ("entity_id", "installed_version", "latest_version"), + [(CONTROLLER_UPDATE_ENTITY, "1.2", "1.2"), (NODE_UPDATE_ENTITY, "10.7", "10.7")], +) async def test_update_entity_no_latest_version( hass: HomeAssistant, - client, - climate_radio_thermostat_ct100_plus_different_endpoints, + client: MagicMock, + climate_radio_thermostat_ct100_plus_different_endpoints: Node, hass_ws_client: WebSocketGenerator, + entity_id: str, + installed_version: str, + latest_version: str, ) -> None: """Test entity with no `latest_version` attr restores state.""" mock_restore_cache_with_extra_data( @@ -910,10 +1136,10 @@ async def test_update_entity_no_latest_version( [ ( State( - UPDATE_ENTITY, + entity_id, STATE_OFF, { - ATTR_INSTALLED_VERSION: "10.7", + ATTR_INSTALLED_VERSION: installed_version, ATTR_LATEST_VERSION: None, ATTR_SKIPPED_VERSION: None, }, @@ -927,24 +1153,33 @@ async def test_update_entity_no_latest_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(UPDATE_ENTITY) + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None - assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + assert state.attributes[ATTR_LATEST_VERSION] == latest_version async def test_update_entity_unload_asleep_node( - hass: HomeAssistant, client, wallmote_central_scene, integration + hass: HomeAssistant, + client: MagicMock, + wallmote_central_scene: Node, + integration: MockConfigEntry, ) -> None: """Test unloading config entry after attempting an update for an asleep node.""" - assert len(client.async_send_command.call_args_list) == 0 + config_entry = integration + assert client.async_send_command.call_count == 0 + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"updates": []} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 - assert len(wallmote_central_scene._listeners["wake up"]) == 2 + # Once call completed for the (awake) controller node. + assert client.async_send_command.call_count == 1 + assert len(wallmote_central_scene._listeners["wake up"]) == 1 - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) + assert client.async_send_command.call_count == 1 assert len(wallmote_central_scene._listeners["wake up"]) == 0 From 9d66b19c0328de6047d4feefa0d3700c8eb5bd2c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 05:20:04 -0400 Subject: [PATCH 0744/1118] Add assumed optimistic to template number entities (#148499) --- homeassistant/components/template/number.py | 234 ++++++++++---------- tests/components/template/test_number.py | 103 +++++++-- 2 files changed, 205 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 31a6338f594..362a7e9d5c5 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -17,14 +17,9 @@ from homeassistant.components.number import ( NumberEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIT_OF_MEASUREMENT, -) +from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -33,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN +from .entity import AbstractTemplateEntity from .helpers import ( async_setup_template_entry, async_setup_template_platform, @@ -40,6 +36,7 @@ from .helpers import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -57,21 +54,15 @@ NUMBER_COMMON_SCHEMA = vol.Schema( vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_STEP): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -) +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -NUMBER_YAML_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } - ) - .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - .extend(NUMBER_COMMON_SCHEMA.schema) -) +NUMBER_YAML_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema @@ -121,69 +112,28 @@ def async_create_preview_number( ) -class StateNumberEntity(TemplateEntity, NumberEntity): - """Representation of a template number.""" +class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity): + """Representation of a template number features.""" - _attr_should_poll = False _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True - def __init__( - self, - hass: HomeAssistant, - config, - unique_id: str | None, - ) -> None: - """Initialize the number.""" - TemplateEntity.__init__(self, hass, config, unique_id) - if TYPE_CHECKING: - assert self._attr_name is not None - - self._value_template = config[CONF_STATE] - self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) - + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" self._step_template = config[CONF_STEP] - self._min_value_template = config[CONF_MIN] - self._max_value_template = config[CONF_MAX] - self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC) + self._min_template = config[CONF_MIN] + self._max_template = config[CONF_MAX] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - self.add_template_attribute( - "_attr_native_value", - self._value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - self.add_template_attribute( - "_attr_native_step", - self._step_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - if self._min_value_template is not None: - self.add_template_attribute( - "_attr_native_min_value", - self._min_value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - if self._max_value_template is not None: - self.add_template_attribute( - "_attr_native_max_value", - self._max_value_template, - validator=vol.Coerce(float), - none_on_template_error=True, - ) - super()._async_setup_templates() - async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" - if self._optimistic: + if self._attr_assumed_state: self._attr_native_value = value self.async_write_ha_state() if set_value := self._action_scripts.get(CONF_SET_VALUE): @@ -194,17 +144,65 @@ class StateNumberEntity(TemplateEntity, NumberEntity): ) -class TriggerNumberEntity(TriggerEntity, NumberEntity): +class StateNumberEntity(TemplateEntity, AbstractTemplateNumber): + """Representation of a template number.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: + """Initialize the number.""" + TemplateEntity.__init__(self, hass, config, unique_id) + AbstractTemplateNumber.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: + self.add_template_attribute( + "_attr_native_value", + self._template, + vol.Coerce(float), + none_on_template_error=True, + ) + if self._step_template is not None: + self.add_template_attribute( + "_attr_native_step", + self._step_template, + vol.Coerce(float), + none_on_template_error=True, + ) + if self._min_template is not None: + self.add_template_attribute( + "_attr_native_min_value", + self._min_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + if self._max_template is not None: + self.add_template_attribute( + "_attr_native_max_value", + self._max_template, + validator=vol.Coerce(float), + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerNumberEntity(TriggerEntity, AbstractTemplateNumber): """Number entity based on trigger data.""" - _entity_id_format = ENTITY_ID_FORMAT domain = NUMBER_DOMAIN - extra_template_keys = ( - CONF_STATE, - CONF_STEP, - CONF_MIN, - CONF_MAX, - ) def __init__( self, @@ -213,47 +211,49 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateNumber.__init__(self, config) - name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) + for key in ( + CONF_STATE, + CONF_STEP, + CONF_MIN, + CONF_MAX, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - def native_value(self) -> float | None: - """Return the currently selected option.""" - return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) - - @property - def native_min_value(self) -> int: - """Return the minimum value.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_MIN, super().native_min_value) + self.add_script( + CONF_SET_VALUE, + config[CONF_SET_VALUE], + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, ) - @property - def native_max_value(self) -> int: - """Return the maximum value.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_MAX, super().native_max_value) - ) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def native_step(self) -> int: - """Return the increment/decrement step.""" - return vol.Any(vol.Coerce(float), None)( - self._rendered.get(CONF_STEP, super().native_step) - ) - - async def async_set_native_value(self, value: float) -> None: - """Set value of the number.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_native_value = value + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, attr in ( + (CONF_STATE, "_attr_native_value"), + (CONF_STEP, "_attr_native_step"), + (CONF_MIN, "_attr_native_min_value"), + (CONF_MAX, "_attr_native_max_value"), + ): + if (rendered := self._rendered.get(key)) is not None: + setattr(self, attr, vol.Any(vol.Coerce(float), None)(rendered)) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() - if set_value := self._action_scripts.get(CONF_SET_VALUE): - await self.async_run_script( - set_value, - run_variables={ATTR_VALUE: value}, - context=self._context, - ) diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 21dea28b73f..0ae98a23ae4 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_ICON, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -63,11 +64,11 @@ _VALUE_INPUT_NUMBER_CONFIG = { } TEST_STATE_ENTITY_ID = "number.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [TEST_STATE_ENTITY_ID], + "entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -191,19 +192,6 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "number": { - "set_value": {"service": "script.set_value"}, - } - } - }, - ) - with assert_setup_component(0, "template"): assert await setup.async_setup_component( hass, @@ -578,6 +566,91 @@ async def test_device_id( assert template_entity.device_id == device_entry.id +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "set_value": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_number") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + 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 + + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _TEST_NUMBER, "value": 2}, + blocking=True, + ) + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 2 + + +@pytest.mark.parametrize( + ("count", "number_config"), + [ + ( + 1, + { + "set_value": [], + "state": "{{ states('number.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_number") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "4.0") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 4 + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "2.0") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_NUMBER) + assert float(state.state) == 2 + + @pytest.mark.parametrize( ("count", "number_config"), [ From 06233b5134c6586810833d4eedaa4b85ca7ab5bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 00:16:16 -1000 Subject: [PATCH 0745/1118] Bump aioesphomeapi to 37.1.5 (#149656) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 17fd72fc939..00d56955aa7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.1.2", + "aioesphomeapi==37.1.5", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index eafa0b0d47f..fd13a55446b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.2 +aioesphomeapi==37.1.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4ed33e539b..cb329428196 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.2 +aioesphomeapi==37.1.5 # homeassistant.components.flo aioflo==2021.11.0 From 03ee97d38f28761ce4a6ff29e5c64f2418d00208 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 30 Jul 2025 12:16:40 +0200 Subject: [PATCH 0746/1118] Clarify description of `turn_away_mode_on.osoenergy` action (#149655) --- homeassistant/components/osoenergy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 60b67731eac..48b99749ca1 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -211,7 +211,7 @@ }, "turn_away_mode_on": { "name": "Set away mode", - "description": "Turns away mode on for the heater", + "description": "Turns on away mode for the water heater", "fields": { "duration_days": { "name": "Duration in days", From ac86f2e2ba9247429dd332a66f09eadc9f0b5449 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 30 Jul 2025 12:21:27 +0200 Subject: [PATCH 0747/1118] Add Frient brand (#149654) --- homeassistant/brands/frient.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/frient.json diff --git a/homeassistant/brands/frient.json b/homeassistant/brands/frient.json new file mode 100644 index 00000000000..e6b4374576f --- /dev/null +++ b/homeassistant/brands/frient.json @@ -0,0 +1,5 @@ +{ + "domain": "frient", + "name": "Frient", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5f4ae434074..1eb37ae87d2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2137,6 +2137,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "frient": { + "name": "Frient", + "iot_standards": [ + "zigbee" + ] + }, "fritzbox": { "name": "FRITZ!Box", "integrations": { From a79d2da9a3765703e68504804bb3b38525db7b9d Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 30 Jul 2025 03:31:32 -0700 Subject: [PATCH 0748/1118] Move group toggle descriptions to data_description (#149625) Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- homeassistant/components/group/strings.json | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index b80b78027bf..bb9ab4b25d8 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -21,12 +21,14 @@ }, "binary_sensor": { "title": "[%key:component::group::config::step::user::title%]", - "description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on.", "data": { "all": "All entities", "entities": "Members", "hide_members": "Hide members", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "If enabled, the group's state is on only if all members are on. If disabled, the group's state is on if any member is on." } }, "button": { @@ -105,6 +107,9 @@ "device_class": "Device class", "state_class": "State class", "unit_of_measurement": "Unit of measurement" + }, + "data_description": { + "ignore_non_numeric": "If enabled, the group's state is calculated if at least one member has a numerical value. If disabled, the group's state is calculated only if all group members have numerical values." } }, "switch": { @@ -120,11 +125,13 @@ "options": { "step": { "binary_sensor": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "button": { @@ -146,11 +153,13 @@ } }, "light": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "lock": { @@ -172,7 +181,6 @@ } }, "sensor": { - "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", @@ -182,14 +190,19 @@ "device_class": "[%key:component::group::config::step::sensor::data::device_class%]", "state_class": "[%key:component::group::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::group::config::step::sensor::data::unit_of_measurement%]" + }, + "data_description": { + "ignore_non_numeric": "[%key:component::group::config::step::sensor::data_description::ignore_non_numeric%]" } }, "switch": { - "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } } } From 15e45df8a7e868902a8e43022ea57e0878853f5e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Jul 2025 13:49:21 +0300 Subject: [PATCH 0749/1118] Use async_create_clientsession in Alexa Devices (#149432) --- homeassistant/components/alexa_devices/__init__.py | 10 ++++------ homeassistant/components/alexa_devices/config_flow.py | 10 ++++------ homeassistant/components/alexa_devices/coordinator.py | 3 +++ homeassistant/components/alexa_devices/manifest.json | 2 +- .../components/alexa_devices/quality_scale.yaml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index fe623c10b33..d18e730afcb 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -2,6 +2,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator @@ -16,7 +17,8 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Set up Alexa Devices platform.""" - coordinator = AmazonDevicesCoordinator(hass, entry) + session = aiohttp_client.async_create_clientsession(hass) + coordinator = AmazonDevicesCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() @@ -29,8 +31,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Unload a config entry.""" - coordinator = entry.runtime_data - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await coordinator.api.close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 5ee3bc2e5f0..3e705d73ade 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import CountrySelector @@ -33,18 +34,15 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect.""" + session = aiohttp_client.async_create_clientsession(hass) api = AmazonEchoApi( + session, data[CONF_COUNTRY], data[CONF_USERNAME], data[CONF_PASSWORD], ) - try: - data = await api.login_mode_interactive(data[CONF_CODE]) - finally: - await api.close() - - return data + return await api.login_mode_interactive(data[CONF_CODE]) class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 7af66f4bb8b..f4a1faa4f81 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -8,6 +8,7 @@ from aioamazondevices.exceptions import ( CannotConnect, CannotRetrieveData, ) +from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME @@ -31,6 +32,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): self, hass: HomeAssistant, entry: AmazonConfigEntry, + session: ClientSession, ) -> None: """Initialize the scanner.""" super().__init__( @@ -41,6 +43,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): update_interval=timedelta(seconds=SCAN_INTERVAL), ) self.api = AmazonEchoApi( + session, entry.data[CONF_COUNTRY], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 74187ba7ed4..90410412dfa 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "silver", - "requirements": ["aioamazondevices==3.5.1"] + "requirements": ["aioamazondevices==4.0.0"] } diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 95433655212..5a2ff55b9b2 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -70,5 +70,5 @@ rules: # Platinum async-dependency: done - inject-websession: todo + inject-websession: done strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index fd13a55446b..93663598733 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.7.1 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.1 +aioamazondevices==4.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb329428196..268d263220a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.7.1 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.5.1 +aioamazondevices==4.0.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 5930ac6425e06cb9097f7b58a927fb7e6a972186 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:27:24 +0200 Subject: [PATCH 0750/1118] Use translation_placeholders in tuya switch descriptions (#149664) --- homeassistant/components/tuya/strings.json | 80 ++--------- homeassistant/components/tuya/switch.py | 132 ++++++++++++------ .../tuya/snapshots/test_switch.ambr | 14 +- 3 files changed, 105 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index fd3a680ed3c..97d623d7c21 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -744,86 +744,26 @@ "switch": { "name": "Switch" }, + "indexed_switch": { + "name": "Switch {index}" + }, "socket": { "name": "Socket" }, + "indexed_socket": { + "name": "Socket {index}" + }, "radio": { "name": "Radio" }, - "alarm_1": { - "name": "Alarm 1" - }, - "alarm_2": { - "name": "Alarm 2" - }, - "alarm_3": { - "name": "Alarm 3" - }, - "alarm_4": { - "name": "Alarm 4" + "indexed_alarm": { + "name": "Alarm {index}" }, "sleep_aid": { "name": "Sleep aid" }, - "switch_1": { - "name": "Switch 1" - }, - "switch_2": { - "name": "Switch 2" - }, - "switch_3": { - "name": "Switch 3" - }, - "switch_4": { - "name": "Switch 4" - }, - "switch_5": { - "name": "Switch 5" - }, - "switch_6": { - "name": "Switch 6" - }, - "switch_7": { - "name": "Switch 7" - }, - "switch_8": { - "name": "Switch 8" - }, - "usb_1": { - "name": "USB 1" - }, - "usb_2": { - "name": "USB 2" - }, - "usb_3": { - "name": "USB 3" - }, - "usb_4": { - "name": "USB 4" - }, - "usb_5": { - "name": "USB 5" - }, - "usb_6": { - "name": "USB 6" - }, - "socket_1": { - "name": "Socket 1" - }, - "socket_2": { - "name": "Socket 2" - }, - "socket_3": { - "name": "Socket 3" - }, - "socket_4": { - "name": "Socket 4" - }, - "socket_5": { - "name": "Socket 5" - }, - "socket_6": { - "name": "Socket 6" + "indexed_usb": { + "name": "USB {index}" }, "ionizer": { "name": "Ionizer" diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 67f3ba9cb81..f6d5df9af73 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -232,35 +232,43 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "ggq": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="switch_3", + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="switch_4", + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="switch_5", + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - translation_key="switch_6", + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, ), SwitchEntityDescription( key=DPCode.SWITCH_7, - translation_key="switch_7", + translation_key="indexed_switch", + translation_placeholders={"index": "7"}, ), SwitchEntityDescription( key=DPCode.SWITCH_8, - translation_key="switch_8", + translation_key="indexed_switch", + translation_placeholders={"index": "8"}, ), ), # Wake Up Light II @@ -272,22 +280,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="alarm_1", + translation_key="indexed_alarm", + translation_placeholders={"index": "1"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="alarm_2", + translation_key="indexed_alarm", + translation_placeholders={"index": "2"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="alarm_3", + translation_key="indexed_alarm", + translation_placeholders={"index": "3"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="alarm_4", + translation_key="indexed_alarm", + translation_placeholders={"index": "4"}, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -324,67 +336,81 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="switch_3", + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="switch_4", + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="switch_5", + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - translation_key="switch_6", + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_7, - translation_key="switch_7", + translation_key="indexed_switch", + translation_placeholders={"index": "7"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_8, - translation_key="switch_8", + translation_key="indexed_switch", + translation_placeholders={"index": "8"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - translation_key="usb_1", + translation_key="indexed_usb", + translation_placeholders={"index": "1"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - translation_key="usb_2", + translation_key="indexed_usb", + translation_placeholders={"index": "2"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - translation_key="usb_3", + translation_key="indexed_usb", + translation_placeholders={"index": "3"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - translation_key="usb_4", + translation_key="indexed_usb", + translation_placeholders={"index": "4"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - translation_key="usb_5", + translation_key="indexed_usb", + translation_placeholders={"index": "5"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - translation_key="usb_6", + translation_key="indexed_usb", + translation_placeholders={"index": "6"}, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -487,57 +513,69 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="socket_1", + translation_key="indexed_socket", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="socket_2", + translation_key="indexed_socket", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="socket_3", + translation_key="indexed_socket", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="socket_4", + translation_key="indexed_socket", + translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - translation_key="socket_5", + translation_key="indexed_socket", + translation_placeholders={"index": "5"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - translation_key="socket_6", + translation_key="indexed_socket", + translation_placeholders={"index": "6"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - translation_key="usb_1", + translation_key="indexed_usb", + translation_placeholders={"index": "1"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - translation_key="usb_2", + translation_key="indexed_usb", + translation_placeholders={"index": "2"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - translation_key="usb_3", + translation_key="indexed_usb", + translation_placeholders={"index": "3"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - translation_key="usb_4", + translation_key="indexed_usb", + translation_placeholders={"index": "4"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - translation_key="usb_5", + translation_key="indexed_usb", + translation_placeholders={"index": "5"}, ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - translation_key="usb_6", + translation_key="indexed_usb", + translation_placeholders={"index": "6"}, ), SwitchEntityDescription( key=DPCode.SWITCH, @@ -698,22 +736,26 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tdq": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - translation_key="switch_3", + translation_key="indexed_switch", + translation_placeholders={"index": "3"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - translation_key="switch_4", + translation_key="indexed_switch", + translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( @@ -746,12 +788,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkcz": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - translation_key="switch_1", + translation_key="indexed_switch", + translation_placeholders={"index": "1"}, device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - translation_key="switch_2", + translation_key="indexed_switch", + translation_placeholders={"index": "2"}, device_class=SwitchDeviceClass.OUTLET, ), ), diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 92243414892..71aa05329aa 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -464,7 +464,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'socket_1', + 'translation_key': 'indexed_socket', 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_1', 'unit_of_measurement': None, }) @@ -513,7 +513,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'socket_2', + 'translation_key': 'indexed_socket', 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_2', 'unit_of_measurement': None, }) @@ -658,7 +658,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch_1', + 'translation_key': 'indexed_switch', 'unique_id': 'tuya.0665305284f3ebe9fdc1switch_1', 'unit_of_measurement': None, }) @@ -995,7 +995,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch_1', + 'translation_key': 'indexed_switch', 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_1', 'unit_of_measurement': None, }) @@ -1044,7 +1044,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch_2', + 'translation_key': 'indexed_switch', 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_2', 'unit_of_measurement': None, }) @@ -1093,7 +1093,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch_3', + 'translation_key': 'indexed_switch', 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_3', 'unit_of_measurement': None, }) @@ -1142,7 +1142,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'switch_4', + 'translation_key': 'indexed_switch', 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_4', 'unit_of_measurement': None, }) From 1eb6d5fe3279519e0bc3f1062b27aa09d3c5a040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 30 Jul 2025 13:35:24 +0200 Subject: [PATCH 0751/1118] Add action for set_program_oven to miele (#149620) --- homeassistant/components/miele/icons.json | 3 + homeassistant/components/miele/services.py | 58 +++++++++++++++++- homeassistant/components/miele/services.yaml | 30 ++++++++++ homeassistant/components/miele/strings.json | 25 ++++++++ tests/components/miele/test_services.py | 62 +++++++++++++++++++- 5 files changed, 173 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 4a0eac7da85..77d94c49ffa 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -110,6 +110,9 @@ }, "set_program": { "service": "mdi:arrow-right-circle-outline" + }, + "set_program_oven": { + "service": "mdi:arrow-right-circle-outline" } } } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 3d73c021b3d..9854196ea65 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -1,12 +1,13 @@ """Services for Miele integration.""" +from datetime import timedelta import logging from typing import cast import aiohttp import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -32,6 +33,19 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema( }, ) +SERVICE_SET_PROGRAM_OVEN = "set_program_oven" +SERVICE_SET_PROGRAM_OVEN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM_ID): cv.positive_int, + vol.Optional(ATTR_TEMPERATURE): cv.positive_int, + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)), + ), + }, +) + SERVICE_GET_PROGRAMS = "get_programs" SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema( { @@ -103,6 +117,36 @@ async def set_program(call: ServiceCall) -> None: ) from ex +async def set_program_oven(call: ServiceCall) -> None: + """Set a program on a Miele oven.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} + if call.data.get(ATTR_DURATION) is not None: + td = call.data[ATTR_DURATION] + data["duration"] = [ + td.seconds // 3600, # hours + (td.seconds // 60) % 60, # minutes + ] + if call.data.get(ATTR_TEMPERATURE) is not None: + data["temperature"] = call.data[ATTR_TEMPERATURE] + try: + await api.set_program(serial_number, data) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_program_oven_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + async def get_programs(call: ServiceCall) -> ServiceResponse: """Get available programs from appliance.""" @@ -172,7 +216,17 @@ async def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" hass.services.async_register( - DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA + DOMAIN, + SERVICE_SET_PROGRAM, + set_program, + SERVICE_SET_PROGRAM_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_OVEN, + set_program_oven, + SERVICE_SET_PROGRAM_OVEN_SCHEMA, ) hass.services.async_register( diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml index 6866e997c45..87114343ad1 100644 --- a/homeassistant/components/miele/services.yaml +++ b/homeassistant/components/miele/services.yaml @@ -23,3 +23,33 @@ set_program: max: 99999 mode: box example: 24 + +set_program_oven: + fields: + device_id: + selector: + device: + integration: miele + required: true + program_id: + required: true + selector: + number: + min: 0 + max: 99999 + mode: box + example: 24 + temperature: + required: false + selector: + number: + min: 30 + max: 300 + unit_of_measurement: "°C" + mode: box + example: 180 + duration: + required: false + selector: + duration: + example: 1:15:00 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 5b5cac16b53..cec4a63feec 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1068,6 +1068,9 @@ "set_program_error": { "message": "'Set program' action failed {status} / {message}." }, + "set_program_oven_error": { + "message": "'Set program on oven' action failed {status} / {message}." + }, "set_state_error": { "message": "Failed to set state for {entity}." } @@ -1096,6 +1099,28 @@ "name": "Program ID" } } + }, + "set_program_oven": { + "name": "Set program on oven", + "description": "[%key:component::miele::services::set_program::description%]", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + }, + "program_id": { + "description": "[%key:component::miele::services::set_program::fields::program_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::program_id::name%]" + }, + "temperature": { + "description": "The target temperature for the oven program.", + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "duration": { + "description": "The duration for the oven program.", + "name": "[%key:component::sensor::entity_component::duration::name%]" + } + } } } } diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py index 2bf0e2deb9c..38b9f064b55 100644 --- a/tests/components/miele/test_services.py +++ b/tests/components/miele/test_services.py @@ -1,5 +1,6 @@ """Tests the services provided by the miele integration.""" +from datetime import timedelta from unittest.mock import MagicMock from aiohttp import ClientResponseError @@ -9,11 +10,13 @@ from voluptuous import MultipleInvalid from homeassistant.components.miele.const import DOMAIN from homeassistant.components.miele.services import ( + ATTR_DURATION, ATTR_PROGRAM_ID, SERVICE_GET_PROGRAMS, SERVICE_SET_PROGRAM, + SERVICE_SET_PROGRAM_OVEN, ) -from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceRegistry @@ -49,6 +52,50 @@ async def test_services( ) +@pytest.mark.parametrize( + ("call_arguments", "miele_arguments"), + [ + ( + {ATTR_PROGRAM_ID: 24}, + {"programId": 24}, + ), + ( + {ATTR_PROGRAM_ID: 25, ATTR_DURATION: timedelta(minutes=75)}, + {"programId": 25, "duration": [1, 15]}, + ), + ( + { + ATTR_PROGRAM_ID: 26, + ATTR_DURATION: timedelta(minutes=135), + ATTR_TEMPERATURE: 180, + }, + {"programId": 26, "duration": [2, 15], "temperature": 180}, + ), + ], +) +async def test_services_oven( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + call_arguments: dict, + miele_arguments: dict, +) -> None: + """Tests that the custom services are correct for ovens.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROGRAM_OVEN, + {ATTR_DEVICE_ID: device.id, **call_arguments}, + blocking=True, + ) + mock_miele_client.set_program.assert_called_once_with( + TEST_APPLIANCE, miele_arguments + ) + + async def test_services_with_response( hass: HomeAssistant, device_registry: DeviceRegistry, @@ -71,11 +118,20 @@ async def test_services_with_response( ) +@pytest.mark.parametrize( + ("service", "error"), + [ + (SERVICE_SET_PROGRAM, "'Set program' action failed"), + (SERVICE_SET_PROGRAM_OVEN, "'Set program on oven' action failed"), + ], +) async def test_service_api_errors( hass: HomeAssistant, device_registry: DeviceRegistry, mock_miele_client: MagicMock, mock_config_entry: MockConfigEntry, + service: str, + error: str, ) -> None: """Test service api errors.""" await setup_integration(hass, mock_config_entry) @@ -83,10 +139,10 @@ async def test_service_api_errors( # Test http error mock_miele_client.set_program.side_effect = ClientResponseError("TestInfo", "test") - with pytest.raises(HomeAssistantError, match="'Set program' action failed"): + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( DOMAIN, - SERVICE_SET_PROGRAM, + service, {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1}, blocking=True, ) From 828f979c782c1c610b7b424221867b871e20ee72 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:43:07 +0200 Subject: [PATCH 0752/1118] Use Tuya device listener in binary sensor tests (#148890) --- tests/components/tuya/__init__.py | 26 ++++++++++++++++++++- tests/components/tuya/conftest.py | 12 ++++++++++ tests/components/tuya/test_binary_sensor.py | 13 ++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index ab2d28ef645..039b8f29290 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch from tuya_sharing import CustomerDevice -from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya import DeviceListener, ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -180,6 +181,29 @@ DEVICE_MOCKS = { } +class MockDeviceListener(DeviceListener): + """Mocked DeviceListener for testing.""" + + async def async_send_device_update( + self, + hass: HomeAssistant, + device: CustomerDevice, + updated_status_properties: dict[str, Any] | None = None, + ) -> None: + """Mock update device method.""" + property_list: list[str] = [] + if updated_status_properties: + for key, value in updated_status_properties.items(): + if key not in device.status: + raise ValueError( + f"Property {key} not found in device status: {device.status}" + ) + device.status[key] = value + property_list.append(key) + self.update_device(device, property_list) + await hass.async_block_till_done() + + async def initialize_entry( hass: HomeAssistant, mock_manager: ManagerCompat, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index cac9359a8d3..73752590637 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util +from . import MockDeviceListener + from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -184,3 +186,13 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev if device.status_range[key].type == "Json": device.status[key] = json_dumps(value) return device + + +@pytest.fixture +def mock_listener( + hass: HomeAssistant, mock_manager: ManagerCompat +) -> MockDeviceListener: + """Create a DeviceListener for testing.""" + listener = MockDeviceListener(hass, mock_manager) + mock_manager.add_device_listener(listener) + return listener diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index f59e325b6cc..9045b28bfa9 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import DEVICE_MOCKS, MockDeviceListener, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -78,16 +78,23 @@ async def test_bitmap( mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + mock_listener: MockDeviceListener, fault_value: int, tankfull: str, defrost: str, wet: str, ) -> None: """Test BITMAP fault sensor on cs_arete_two_12l_dehumidifier_air_purifier.""" - mock_device.status["fault"] = fault_value - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == "off" + assert hass.states.get("binary_sensor.dehumidifier_defrost").state == "off" + assert hass.states.get("binary_sensor.dehumidifier_wet").state == "off" + + await mock_listener.async_send_device_update( + hass, mock_device, {"fault": fault_value} + ) + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == tankfull assert hass.states.get("binary_sensor.dehumidifier_defrost").state == defrost assert hass.states.get("binary_sensor.dehumidifier_wet").state == wet From 749fc318ca72471a6bc0ca07c12fb08616e0f586 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:22:55 +0200 Subject: [PATCH 0753/1118] Validate selectors in the trigger helper (#149662) --- homeassistant/helpers/trigger.py | 9 ++++-- tests/helpers/test_trigger.py | 49 ++++++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 46b3d883865..de3f71c4834 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_ENABLED, CONF_ID, CONF_PLATFORM, + CONF_SELECTOR, CONF_VARIABLES, ) from homeassistant.core import ( @@ -41,8 +42,9 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE -from . import config_validation as cv +from . import config_validation as cv, selector from .integration_platform import async_process_integration_platforms +from .selector import TargetSelector from .template import Template from .typing import ConfigType, TemplateVarsType @@ -73,12 +75,15 @@ TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") # Basic schemas to sanity check the trigger descriptions, # full validation is done by hassfest.triggers _FIELD_SCHEMA = vol.Schema( - {}, + { + vol.Optional(CONF_SELECTOR): selector.validate_selector, + }, extra=vol.ALLOW_EXTRA, ) _TRIGGER_SCHEMA = vol.Schema( { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), }, extra=vol.ALLOW_EXTRA, diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index ba9db9cb053..050420d0195 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -569,7 +569,15 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" tag_trigger_descriptions = """ - tag: {} + tag: + fields: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -611,9 +619,16 @@ async def test_async_get_all_descriptions( "fields": { "event": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, }, - "offset": {"selector": {"time": None}}, + "offset": {"selector": {"time": {}}}, } } } @@ -639,13 +654,35 @@ async def test_async_get_all_descriptions( "fields": { "event": { "example": "sunrise", - "selector": {"select": {"options": ["sunrise", "sunset"]}}, + "selector": { + "select": { + "custom_value": False, + "multiple": False, + "options": ["sunrise", "sunset"], + "sort": False, + } + }, }, - "offset": {"selector": {"time": None}}, + "offset": {"selector": {"time": {}}}, } }, DOMAIN_TAG: { - "fields": {}, + "fields": { + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + "reorder": False, + }, + }, + }, + } }, } From 6c2a6628387bf208e62b1079154f937fc0602bf4 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:48:24 -0400 Subject: [PATCH 0754/1118] Add config flow to template cover platform (#149433) --- .../components/template/config_flow.py | 42 ++++++++++ homeassistant/components/template/cover.py | 77 +++++++++++++++---- homeassistant/components/template/helpers.py | 4 +- .../components/template/strings.json | 76 ++++++++++++++++++ .../template/snapshots/test_cover.ambr | 16 ++++ tests/components/template/test_config_flow.py | 50 ++++++++++++ tests/components/template/test_cover.py | 55 ++++++++++++- 7 files changed, 303 insertions(+), 17 deletions(-) create mode 100644 tests/components/template/snapshots/test_cover.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 7e06ef51a4b..7963f525b7a 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, @@ -62,6 +63,15 @@ from .const import ( CONF_TURN_ON, DOMAIN, ) +from .cover import ( + CLOSE_ACTION, + CONF_OPEN_AND_CLOSE, + CONF_POSITION, + OPEN_ACTION, + POSITION_ACTION, + STOP_ACTION, + async_create_preview_cover, +) from .number import ( CONF_MAX, CONF_MIN, @@ -143,6 +153,26 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.COVER: + schema |= _SCHEMA_STATE | { + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(), + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): selector.ActionSelector(), + vol.Optional(STOP_ACTION): selector.ActionSelector(), + vol.Optional(CONF_POSITION): selector.TemplateSelector(), + vol.Optional(POSITION_ACTION): selector.ActionSelector(), + } + if flow_type == "config": + schema |= { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in CoverDeviceClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="cover_device_class", + sort=True, + ), + ) + } + if domain == Platform.IMAGE: schema |= { vol.Required(CONF_URL): selector.TemplateSelector(), @@ -327,6 +357,7 @@ TEMPLATE_TYPES = [ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.COVER, Platform.IMAGE, Platform.NUMBER, Platform.SELECT, @@ -350,6 +381,11 @@ CONFIG_FLOW = { config_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.COVER: SchemaFlowFormStep( + config_schema(Platform.COVER), + preview="template", + validate_user_input=validate_user_input(Platform.COVER), + ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), preview="template", @@ -394,6 +430,11 @@ OPTIONS_FLOW = { options_schema(Platform.BUTTON), validate_user_input=validate_user_input(Platform.BUTTON), ), + Platform.COVER: SchemaFlowFormStep( + options_schema(Platform.COVER), + preview="template", + validate_user_input=validate_user_input(Platform.COVER), + ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), preview="template", @@ -427,6 +468,7 @@ CREATE_PREVIEW_ENTITY: dict[ ] = { Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.COVER: async_create_preview_cover, Platform.NUMBER: async_create_preview_number, Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e8739fa8207..caac8cf5a1d 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -18,6 +18,7 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_COVERS, CONF_DEVICE_CLASS, @@ -31,14 +32,22 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, @@ -91,23 +100,29 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Cover" +COVER_COMMON_SCHEMA = vol.Schema( + { + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_POSITION): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TILT): cv.template, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + } +) + COVER_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_POSITION): cv.template, - vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT): cv.template, - vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } ) - .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA), + .extend(COVER_COMMON_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -139,6 +154,11 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} ) +COVER_CONFIG_ENTRY_SCHEMA = vol.All( + COVER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), +) + async def async_setup_platform( hass: HomeAssistant, @@ -160,6 +180,37 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateCoverEntity, + COVER_CONFIG_ENTRY_SCHEMA, + True, + ) + + +@callback +def async_create_preview_cover( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateCoverEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateCoverEntity, + COVER_CONFIG_ENTRY_SCHEMA, + True, + ) + + class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 25f7011c794..a26b7bb0df1 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -242,7 +242,7 @@ async def async_setup_template_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, state_entity_cls: type[TemplateEntity], - config_schema: vol.Schema, + config_schema: vol.Schema | vol.All, replace_value_template: bool = False, ) -> None: """Setup the Template from a config entry.""" @@ -267,7 +267,7 @@ def async_setup_template_preview[T: TemplateEntity]( name: str, config: ConfigType, state_entity_cls: type[T], - schema: vol.Schema, + schema: vol.Schema | vol.All, replace_value_template: bool = False, ) -> T: """Setup the Template preview.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index be91b27e485..36bca174ef6 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -80,6 +80,37 @@ }, "title": "Template button" }, + "cover": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "device_class": "[%key:component::template::common::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "open_cover": "Actions on open", + "close_cover": "Actions on close", + "stop_cover": "Actions on stop", + "position": "Position", + "set_cover_position": "Actions on set position" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the cover. Valid output values from the template are `open`, `opening`, `closing` and `closed` which are directly mapped to the corresponding states. If both a state and a position are specified, only `opening` and `closing` are set from the state template.", + "open_cover": "Defines actions to run when the cover is opened.", + "close_cover": "Defines actions to run when the cover is closed.", + "stop_cover": "Defines actions to run when the cover is stopped.", + "position": "Defines a template to get the position of the cover. Value values are numbers between `0` (`closed`) and `100` (`open`).", + "set_cover_position": "Defines actions to run when the cover is given a `set_cover_position` command." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template cover" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -173,6 +204,7 @@ "alarm_control_panel": "Template an alarm control panel", "binary_sensor": "Template a binary sensor", "button": "Template a button", + "cover": "Template a cover", "image": "Template an image", "number": "Template a number", "select": "Template a select", @@ -270,6 +302,36 @@ }, "title": "[%key:component::template::config::step::button::title%]" }, + + "cover": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "open_cover": "[%key:component::template::config::step::cover::data::open_cover%]", + "close_cover": "[%key:component::template::config::step::cover::data::close_cover%]", + "stop_cover": "[%key:component::template::config::step::cover::data::stop_cover%]", + "position": "[%key:component::template::config::step::cover::data::position%]", + "set_cover_position": "[%key:component::template::config::step::cover::data::set_cover_position%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::cover::data_description::state%]", + "open_cover": "[%key:component::template::config::step::cover::data_description::open_cover%]", + "close_cover": "[%key:component::template::config::step::cover::data_description::close_cover%]", + "stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]", + "position": "[%key:component::template::config::step::cover::data_description::position%]", + "set_cover_position": "[%key:component::template::config::step::cover::data_description::set_cover_position%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "[%key:component::template::config::step::cover::title%]" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -425,6 +487,20 @@ "update": "[%key:component::button::entity_component::update::name%]" } }, + "cover_device_class": { + "options": { + "awning": "[%key:component::cover::entity_component::awning::name%]", + "blind": "[%key:component::cover::entity_component::blind::name%]", + "curtain": "[%key:component::cover::entity_component::curtain::name%]", + "damper": "[%key:component::cover::entity_component::damper::name%]", + "door": "[%key:component::cover::entity_component::door::name%]", + "garage": "[%key:component::cover::entity_component::garage::name%]", + "gate": "[%key:component::cover::entity_component::gate::name%]", + "shade": "[%key:component::cover::entity_component::shade::name%]", + "shutter": "[%key:component::cover::entity_component::shutter::name%]", + "window": "[%key:component::cover::entity_component::window::name%]" + } + }, "sensor_device_class": { "options": { "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", diff --git a/tests/components/template/snapshots/test_cover.ambr b/tests/components/template/snapshots/test_cover.ambr new file mode 100644 index 00000000000..177dc8c883b --- /dev/null +++ b/tests/components/template/snapshots/test_cover.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 22acb1b2292..8d7f2e6d89c 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -121,6 +121,34 @@ BINARY_SENSOR_OPTIONS = { }, {}, ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + "open", + {"one": "open", "two": "closed"}, + {}, + { + "device_class": "garage", + "set_cover_position": [ + { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"position": "{{ position }}"}, + } + ], + }, + { + "device_class": "garage", + "set_cover_position": [ + { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"position": "{{ position }}"}, + } + ], + }, + {}, + ), ( "image", {"url": "{{ states('sensor.one') }}"}, @@ -288,6 +316,12 @@ async def test_config_flow( {}, {}, ), + ( + "cover", + {"state": "{{ 'open' }}"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + ), ( "image", { @@ -474,6 +508,16 @@ async def test_config_flow_device( }, "state", ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + {"state": "{{ states('cover.two') }}"}, + ["open", "closed"], + {"one": "open", "two": "closed"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + "state", + ), ( "image", { @@ -1315,6 +1359,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "cover", + {"state": "{{ states('cover.one') }}"}, + {"set_cover_position": []}, + {"set_cover_position": []}, + ), ( "image", { diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 48f45d879cd..dc3428330b0 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import cover, template from homeassistant.components.cover import ( @@ -32,9 +33,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_cover" TEST_ENTITY_ID = f"cover.{TEST_OBJECT_ID}" @@ -1604,3 +1606,52 @@ async def test_empty_action_config( state.attributes["supported_features"] == CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | supported_feature ) + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a cover from a config entry.""" + + hass.states.async_set( + "cover.test_state", + "open", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('cover.test_state') }}", + "set_cover_position": [], + "template_type": COVER_DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("cover.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + cover.DOMAIN, + {"name": "My template", "state": "{{ 'open' }}", "set_cover_position": []}, + ) + + assert state["state"] == CoverState.OPEN From 1a75a88c76b94956ff2dbf50520a90573f5e2a84 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Jul 2025 15:52:31 +0300 Subject: [PATCH 0755/1118] Add actions to Alexa Devices (#145645) --- .../components/alexa_devices/__init__.py | 13 +- .../components/alexa_devices/icons.json | 8 + .../components/alexa_devices/services.py | 121 ++++ .../components/alexa_devices/services.yaml | 504 +++++++++++++++++ .../components/alexa_devices/strings.json | 523 +++++++++++++++++- tests/components/alexa_devices/conftest.py | 1 + tests/components/alexa_devices/const.py | 2 + .../snapshots/test_services.ambr | 77 +++ .../components/alexa_devices/test_services.py | 195 +++++++ 9 files changed, 1442 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/alexa_devices/services.py create mode 100644 homeassistant/components/alexa_devices/services.yaml create mode 100644 tests/components/alexa_devices/snapshots/test_services.ambr create mode 100644 tests/components/alexa_devices/test_services.py diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index d18e730afcb..9df0e60850e 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -2,9 +2,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -13,6 +16,14 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Alexa Devices component.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Set up Alexa Devices platform.""" diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index 492f89b8fe4..bedd4af1734 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -38,5 +38,13 @@ } } } + }, + "services": { + "send_sound": { + "service": "mdi:cast-audio" + }, + "send_text_command": { + "service": "mdi:microphone-message" + } } } diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py new file mode 100644 index 00000000000..5463c7a4319 --- /dev/null +++ b/homeassistant/components/alexa_devices/services.py @@ -0,0 +1,121 @@ +"""Support for services.""" + +from aioamazondevices.sounds import SOUNDS_LIST +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .const import DOMAIN +from .coordinator import AmazonConfigEntry + +ATTR_TEXT_COMMAND = "text_command" +ATTR_SOUND = "sound" +ATTR_SOUND_VARIANT = "sound_variant" +SERVICE_TEXT_COMMAND = "send_text_command" +SERVICE_SOUND_NOTIFICATION = "send_sound" + +SCHEMA_SOUND_SERVICE = vol.Schema( + { + vol.Required(ATTR_SOUND): cv.string, + vol.Required(ATTR_SOUND_VARIANT): cv.positive_int, + vol.Required(ATTR_DEVICE_ID): cv.string, + }, +) +SCHEMA_CUSTOM_COMMAND = vol.Schema( + { + vol.Required(ATTR_TEXT_COMMAND): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) + + +@callback +def async_get_entry_id_for_service_call( + call: ServiceCall, +) -> tuple[dr.DeviceEntry, AmazonConfigEntry]: + """Get the entry ID related to a service call (by device ID).""" + device_registry = dr.async_get(call.hass) + device_id = call.data[ATTR_DEVICE_ID] + if (device_entry := device_registry.async_get(device_id)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + for entry_id in device_entry.config_entries: + if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"entry": entry.title}, + ) + return (device_entry, entry) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"device_id": device_id}, + ) + + +async def _async_execute_action(call: ServiceCall, attribute: str) -> None: + """Execute action on the device.""" + device, config_entry = async_get_entry_id_for_service_call(call) + assert device.serial_number + value: str = call.data[attribute] + + coordinator = config_entry.runtime_data + + if attribute == ATTR_SOUND: + variant: int = call.data[ATTR_SOUND_VARIANT] + pad = "_" if variant > 10 else "_0" + file = f"{value}{pad}{variant!s}" + if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_value", + translation_placeholders={"sound": value, "variant": str(variant)}, + ) + await coordinator.api.call_alexa_sound( + coordinator.data[device.serial_number], file + ) + elif attribute == ATTR_TEXT_COMMAND: + await coordinator.api.call_alexa_text_command( + coordinator.data[device.serial_number], value + ) + + +async def async_send_sound_notification(call: ServiceCall) -> None: + """Send a sound notification to a AmazonDevice.""" + await _async_execute_action(call, ATTR_SOUND) + + +async def async_send_text_command(call: ServiceCall) -> None: + """Send a custom command to a AmazonDevice.""" + await _async_execute_action(call, ATTR_TEXT_COMMAND) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amazon Devices integration.""" + for service_name, method, schema in ( + ( + SERVICE_SOUND_NOTIFICATION, + async_send_sound_notification, + SCHEMA_SOUND_SERVICE, + ), + ( + SERVICE_TEXT_COMMAND, + async_send_text_command, + SCHEMA_CUSTOM_COMMAND, + ), + ): + hass.services.async_register(DOMAIN, service_name, method, schema=schema) diff --git a/homeassistant/components/alexa_devices/services.yaml b/homeassistant/components/alexa_devices/services.yaml new file mode 100644 index 00000000000..d9eef28aea2 --- /dev/null +++ b/homeassistant/components/alexa_devices/services.yaml @@ -0,0 +1,504 @@ +send_text_command: + fields: + device_id: + required: true + selector: + device: + integration: alexa_devices + text_command: + required: true + example: "Play B.B.C. on TuneIn" + selector: + text: + +send_sound: + fields: + device_id: + required: true + selector: + device: + integration: alexa_devices + sound_variant: + required: true + example: 1 + default: 1 + selector: + number: + min: 1 + max: 50 + sound: + required: true + example: amzn_sfx_doorbell_chime + default: amzn_sfx_doorbell_chime + selector: + select: + options: + - air_horn + - air_horns + - airboat + - airport + - aliens + - amzn_sfx_airplane_takeoff_whoosh + - amzn_sfx_army_march_clank_7x + - amzn_sfx_army_march_large_8x + - amzn_sfx_army_march_small_8x + - amzn_sfx_baby_big_cry + - amzn_sfx_baby_cry + - amzn_sfx_baby_fuss + - amzn_sfx_battle_group_clanks + - amzn_sfx_battle_man_grunts + - amzn_sfx_battle_men_grunts + - amzn_sfx_battle_men_horses + - amzn_sfx_battle_noisy_clanks + - amzn_sfx_battle_yells_men + - amzn_sfx_battle_yells_men_run + - amzn_sfx_bear_groan_roar + - amzn_sfx_bear_roar_grumble + - amzn_sfx_bear_roar_small + - amzn_sfx_beep_1x + - amzn_sfx_bell_med_chime + - amzn_sfx_bell_short_chime + - amzn_sfx_bell_timer + - amzn_sfx_bicycle_bell_ring + - amzn_sfx_bird_chickadee_chirp_1x + - amzn_sfx_bird_chickadee_chirps + - amzn_sfx_bird_forest + - amzn_sfx_bird_forest_short + - amzn_sfx_bird_robin_chirp_1x + - amzn_sfx_boing_long_1x + - amzn_sfx_boing_med_1x + - amzn_sfx_boing_short_1x + - amzn_sfx_bus_drive_past + - amzn_sfx_buzz_electronic + - amzn_sfx_buzzer_loud_alarm + - amzn_sfx_buzzer_small + - amzn_sfx_car_accelerate + - amzn_sfx_car_accelerate_noisy + - amzn_sfx_car_click_seatbelt + - amzn_sfx_car_close_door_1x + - amzn_sfx_car_drive_past + - amzn_sfx_car_honk_1x + - amzn_sfx_car_honk_2x + - amzn_sfx_car_honk_3x + - amzn_sfx_car_honk_long_1x + - amzn_sfx_car_into_driveway + - amzn_sfx_car_into_driveway_fast + - amzn_sfx_car_slam_door_1x + - amzn_sfx_car_undo_seatbelt + - amzn_sfx_cat_angry_meow_1x + - amzn_sfx_cat_angry_screech_1x + - amzn_sfx_cat_long_meow_1x + - amzn_sfx_cat_meow_1x + - amzn_sfx_cat_purr + - amzn_sfx_cat_purr_meow + - amzn_sfx_chicken_cluck + - amzn_sfx_church_bell_1x + - amzn_sfx_church_bells_ringing + - amzn_sfx_clear_throat_ahem + - amzn_sfx_clock_ticking + - amzn_sfx_clock_ticking_long + - amzn_sfx_copy_machine + - amzn_sfx_cough + - amzn_sfx_crow_caw_1x + - amzn_sfx_crowd_applause + - amzn_sfx_crowd_bar + - amzn_sfx_crowd_bar_rowdy + - amzn_sfx_crowd_boo + - amzn_sfx_crowd_cheer_med + - amzn_sfx_crowd_excited_cheer + - amzn_sfx_dog_med_bark_1x + - amzn_sfx_dog_med_bark_2x + - amzn_sfx_dog_med_bark_growl + - amzn_sfx_dog_med_growl_1x + - amzn_sfx_dog_med_woof_1x + - amzn_sfx_dog_small_bark_2x + - amzn_sfx_door_open + - amzn_sfx_door_shut + - amzn_sfx_doorbell + - amzn_sfx_doorbell_buzz + - amzn_sfx_doorbell_chime + - amzn_sfx_drinking_slurp + - amzn_sfx_drum_and_cymbal + - amzn_sfx_drum_comedy + - amzn_sfx_earthquake_rumble + - amzn_sfx_electric_guitar + - amzn_sfx_electronic_beep + - amzn_sfx_electronic_major_chord + - amzn_sfx_elephant + - amzn_sfx_elevator_bell_1x + - amzn_sfx_elevator_open_bell + - amzn_sfx_fairy_melodic_chimes + - amzn_sfx_fairy_sparkle_chimes + - amzn_sfx_faucet_drip + - amzn_sfx_faucet_running + - amzn_sfx_fireplace_crackle + - amzn_sfx_fireworks + - amzn_sfx_fireworks_firecrackers + - amzn_sfx_fireworks_launch + - amzn_sfx_fireworks_whistles + - amzn_sfx_food_frying + - amzn_sfx_footsteps + - amzn_sfx_footsteps_muffled + - amzn_sfx_ghost_spooky + - amzn_sfx_glass_on_table + - amzn_sfx_glasses_clink + - amzn_sfx_horse_gallop_4x + - amzn_sfx_horse_huff_whinny + - amzn_sfx_horse_neigh + - amzn_sfx_horse_neigh_low + - amzn_sfx_horse_whinny + - amzn_sfx_human_walking + - amzn_sfx_jar_on_table_1x + - amzn_sfx_kitchen_ambience + - amzn_sfx_large_crowd_cheer + - amzn_sfx_large_fire_crackling + - amzn_sfx_laughter + - amzn_sfx_laughter_giggle + - amzn_sfx_lightning_strike + - amzn_sfx_lion_roar + - amzn_sfx_magic_blast_1x + - amzn_sfx_monkey_calls_3x + - amzn_sfx_monkey_chimp + - amzn_sfx_monkeys_chatter + - amzn_sfx_motorcycle_accelerate + - amzn_sfx_motorcycle_engine_idle + - amzn_sfx_motorcycle_engine_rev + - amzn_sfx_musical_drone_intro + - amzn_sfx_oars_splashing_rowboat + - amzn_sfx_object_on_table_2x + - amzn_sfx_ocean_wave_1x + - amzn_sfx_ocean_wave_on_rocks_1x + - amzn_sfx_ocean_wave_surf + - amzn_sfx_people_walking + - amzn_sfx_person_running + - amzn_sfx_piano_note_1x + - amzn_sfx_punch + - amzn_sfx_rain + - amzn_sfx_rain_on_roof + - amzn_sfx_rain_thunder + - amzn_sfx_rat_squeak_2x + - amzn_sfx_rat_squeaks + - amzn_sfx_raven_caw_1x + - amzn_sfx_raven_caw_2x + - amzn_sfx_restaurant_ambience + - amzn_sfx_rooster_crow + - amzn_sfx_scifi_air_escaping + - amzn_sfx_scifi_alarm + - amzn_sfx_scifi_alien_voice + - amzn_sfx_scifi_boots_walking + - amzn_sfx_scifi_close_large_explosion + - amzn_sfx_scifi_door_open + - amzn_sfx_scifi_engines_on + - amzn_sfx_scifi_engines_on_large + - amzn_sfx_scifi_engines_on_short_burst + - amzn_sfx_scifi_explosion + - amzn_sfx_scifi_explosion_2x + - amzn_sfx_scifi_incoming_explosion + - amzn_sfx_scifi_laser_gun_battle + - amzn_sfx_scifi_laser_gun_fires + - amzn_sfx_scifi_laser_gun_fires_large + - amzn_sfx_scifi_long_explosion_1x + - amzn_sfx_scifi_missile + - amzn_sfx_scifi_motor_short_1x + - amzn_sfx_scifi_open_airlock + - amzn_sfx_scifi_radar_high_ping + - amzn_sfx_scifi_radar_low + - amzn_sfx_scifi_radar_medium + - amzn_sfx_scifi_run_away + - amzn_sfx_scifi_sheilds_up + - amzn_sfx_scifi_short_low_explosion + - amzn_sfx_scifi_small_whoosh_flyby + - amzn_sfx_scifi_small_zoom_flyby + - amzn_sfx_scifi_sonar_ping_3x + - amzn_sfx_scifi_sonar_ping_4x + - amzn_sfx_scifi_spaceship_flyby + - amzn_sfx_scifi_timer_beep + - amzn_sfx_scifi_zap_backwards + - amzn_sfx_scifi_zap_electric + - amzn_sfx_sheep_baa + - amzn_sfx_sheep_bleat + - amzn_sfx_silverware_clank + - amzn_sfx_sirens + - amzn_sfx_sleigh_bells + - amzn_sfx_small_stream + - amzn_sfx_sneeze + - amzn_sfx_stream + - amzn_sfx_strong_wind_desert + - amzn_sfx_strong_wind_whistling + - amzn_sfx_subway_leaving + - amzn_sfx_subway_passing + - amzn_sfx_subway_stopping + - amzn_sfx_swoosh_cartoon_fast + - amzn_sfx_swoosh_fast_1x + - amzn_sfx_swoosh_fast_6x + - amzn_sfx_test_tone + - amzn_sfx_thunder_rumble + - amzn_sfx_toilet_flush + - amzn_sfx_trumpet_bugle + - amzn_sfx_turkey_gobbling + - amzn_sfx_typing_medium + - amzn_sfx_typing_short + - amzn_sfx_typing_typewriter + - amzn_sfx_vacuum_off + - amzn_sfx_vacuum_on + - amzn_sfx_walking_in_mud + - amzn_sfx_walking_in_snow + - amzn_sfx_walking_on_grass + - amzn_sfx_water_dripping + - amzn_sfx_water_droplets + - amzn_sfx_wind_strong_gusting + - amzn_sfx_wind_whistling_desert + - amzn_sfx_wings_flap_4x + - amzn_sfx_wings_flap_fast + - amzn_sfx_wolf_howl + - amzn_sfx_wolf_young_howl + - amzn_sfx_wooden_door + - amzn_sfx_wooden_door_creaks_long + - amzn_sfx_wooden_door_creaks_multiple + - amzn_sfx_wooden_door_creaks_open + - amzn_ui_sfx_gameshow_bridge + - amzn_ui_sfx_gameshow_countdown_loop_32s_full + - amzn_ui_sfx_gameshow_countdown_loop_64s_full + - amzn_ui_sfx_gameshow_countdown_loop_64s_minimal + - amzn_ui_sfx_gameshow_intro + - amzn_ui_sfx_gameshow_negative_response + - amzn_ui_sfx_gameshow_neutral_response + - amzn_ui_sfx_gameshow_outro + - amzn_ui_sfx_gameshow_player1 + - amzn_ui_sfx_gameshow_player2 + - amzn_ui_sfx_gameshow_player3 + - amzn_ui_sfx_gameshow_player4 + - amzn_ui_sfx_gameshow_positive_response + - amzn_ui_sfx_gameshow_tally_negative + - amzn_ui_sfx_gameshow_tally_positive + - amzn_ui_sfx_gameshow_waiting_loop_30s + - anchor + - answering_machines + - arcs_sparks + - arrows_bows + - baby + - back_up_beeps + - bars_restaurants + - baseball + - basketball + - battles + - beeps_tones + - bell + - bikes + - billiards + - board_games + - body + - boing + - books + - bow_wash + - box + - break_shatter_smash + - breaks + - brooms_mops + - bullets + - buses + - buzz + - buzz_hums + - buzzers + - buzzers_pistols + - cables_metal + - camera + - cannons + - car_alarm + - car_alarms + - car_cell_phones + - carnivals_fairs + - cars + - casino + - casinos + - cellar + - chimes + - chimes_bells + - chorus + - christmas + - church_bells + - clock + - cloth + - concrete + - construction + - construction_factory + - crashes + - crowds + - debris + - dining_kitchens + - dinosaurs + - dripping + - drops + - electric + - electrical + - elevator + - evolution_monsters + - explosions + - factory + - falls + - fax_scanner_copier + - feedback_mics + - fight + - fire + - fire_extinguisher + - fireballs + - fireworks + - fishing_pole + - flags + - football + - footsteps + - futuristic + - futuristic_ship + - gameshow + - gear + - ghosts_demons + - giant_monster + - glass + - glasses_clink + - golf + - gorilla + - grenade_lanucher + - griffen + - gyms_locker_rooms + - handgun_loading + - handgun_shot + - handle + - hands + - heartbeats_ekg + - helicopter + - high_tech + - hit_punch_slap + - hits + - horns + - horror + - hot_tub_filling_up + - human + - human_vocals + - hygene # codespell:ignore + - ice_skating + - ignitions + - infantry + - intro + - jet + - juggling + - key_lock + - kids + - knocks + - lab_equip + - lacrosse + - lamps_lanterns + - leather + - liquid_suction + - locker_doors + - machine_gun + - magic_spells + - medium_large_explosions + - metal + - modern_rings + - money_coins + - motorcycles + - movement + - moves + - nature + - oar_boat + - pagers + - paintball + - paper + - parachute + - pay_phones + - phone_beeps + - pigmy_bats + - pills + - pour_water + - power_up_down + - printers + - prison + - public_space + - racquetball + - radios_static + - rain + - rc_airplane + - rc_car + - refrigerators_freezers + - regular + - respirator + - rifle + - roller_coaster + - rollerskates_rollerblades + - room_tones + - ropes_climbing + - rotary_rings + - rowboat_canoe + - rubber + - running + - sails + - sand_gravel + - screen_doors + - screens + - seats_stools + - servos + - shoes_boots + - shotgun + - shower + - sink_faucet + - sink_filling_water + - sink_run_and_off + - sink_water_splatter + - sirens + - skateboards + - ski + - skids_tires + - sled + - slides + - small_explosions + - snow + - snowmobile + - soldiers + - splash_water + - splashes_sprays + - sports_whistles + - squeaks + - squeaky + - stairs + - steam + - submarine_diesel + - swing_doors + - switches_levers + - swords + - tape + - tape_machine + - televisions_shows + - tennis_pingpong + - textile + - throw + - thunder + - ticks + - timer + - toilet_flush + - tone + - tones_noises + - toys + - tractors + - traffic + - train + - trucks_vans + - turnstiles + - typing + - umbrella + - underwater + - vampires + - various + - video_tunes + - volcano_earthquake + - watches + - water + - water_running + - werewolves + - winches_gears + - wind + - wood + - wood_boat + - woosh + - zap + - zippers + translation_key: sound diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 19cc39cab42..1b1150d5649 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -4,7 +4,8 @@ "data_description_country": "The country where your Amazon account is registered.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", - "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." + "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.", + "device_id_description": "The ID of the device to send the command to." }, "config": { "flow_title": "{username}", @@ -84,12 +85,532 @@ } } }, + "services": { + "send_sound": { + "name": "Send sound", + "description": "Sends a sound to a device", + "fields": { + "device_id": { + "name": "Device", + "description": "[%key:component::alexa_devices::common::device_id_description%]" + }, + "sound": { + "name": "Alexa Skill sound file", + "description": "The sound file to play." + }, + "sound_variant": { + "name": "Sound variant", + "description": "The variant of the sound to play." + } + } + }, + "send_text_command": { + "name": "Send text command", + "description": "Sends a text command to a device", + "fields": { + "text_command": { + "name": "Alexa text command", + "description": "The text command to send." + }, + "device_id": { + "name": "Device", + "description": "[%key:component::alexa_devices::common::device_id_description%]" + } + } + } + }, + "selector": { + "sound": { + "options": { + "air_horn": "Air Horn", + "air_horns": "Air Horns", + "airboat": "Airboat", + "airport": "Airport", + "aliens": "Aliens", + "amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh", + "amzn_sfx_army_march_clank_7x": "Army March Clank 7x", + "amzn_sfx_army_march_large_8x": "Army March Large 8x", + "amzn_sfx_army_march_small_8x": "Army March Small 8x", + "amzn_sfx_baby_big_cry": "Baby Big Cry", + "amzn_sfx_baby_cry": "Baby Cry", + "amzn_sfx_baby_fuss": "Baby Fuss", + "amzn_sfx_battle_group_clanks": "Battle Group Clanks", + "amzn_sfx_battle_man_grunts": "Battle Man Grunts", + "amzn_sfx_battle_men_grunts": "Battle Men Grunts", + "amzn_sfx_battle_men_horses": "Battle Men Horses", + "amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks", + "amzn_sfx_battle_yells_men": "Battle Yells Men", + "amzn_sfx_battle_yells_men_run": "Battle Yells Men Run", + "amzn_sfx_bear_groan_roar": "Bear Groan Roar", + "amzn_sfx_bear_roar_grumble": "Bear Roar Grumble", + "amzn_sfx_bear_roar_small": "Bear Roar Small", + "amzn_sfx_beep_1x": "Beep 1x", + "amzn_sfx_bell_med_chime": "Bell Med Chime", + "amzn_sfx_bell_short_chime": "Bell Short Chime", + "amzn_sfx_bell_timer": "Bell Timer", + "amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring", + "amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x", + "amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps", + "amzn_sfx_bird_forest": "Bird Forest", + "amzn_sfx_bird_forest_short": "Bird Forest Short", + "amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x", + "amzn_sfx_boing_long_1x": "Boing Long 1x", + "amzn_sfx_boing_med_1x": "Boing Med 1x", + "amzn_sfx_boing_short_1x": "Boing Short 1x", + "amzn_sfx_bus_drive_past": "Bus Drive Past", + "amzn_sfx_buzz_electronic": "Buzz Electronic", + "amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm", + "amzn_sfx_buzzer_small": "Buzzer Small", + "amzn_sfx_car_accelerate": "Car Accelerate", + "amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy", + "amzn_sfx_car_click_seatbelt": "Car Click Seatbelt", + "amzn_sfx_car_close_door_1x": "Car Close Door 1x", + "amzn_sfx_car_drive_past": "Car Drive Past", + "amzn_sfx_car_honk_1x": "Car Honk 1x", + "amzn_sfx_car_honk_2x": "Car Honk 2x", + "amzn_sfx_car_honk_3x": "Car Honk 3x", + "amzn_sfx_car_honk_long_1x": "Car Honk Long 1x", + "amzn_sfx_car_into_driveway": "Car Into Driveway", + "amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast", + "amzn_sfx_car_slam_door_1x": "Car Slam Door 1x", + "amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt", + "amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x", + "amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x", + "amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x", + "amzn_sfx_cat_meow_1x": "Cat Meow 1x", + "amzn_sfx_cat_purr": "Cat Purr", + "amzn_sfx_cat_purr_meow": "Cat Purr Meow", + "amzn_sfx_chicken_cluck": "Chicken Cluck", + "amzn_sfx_church_bell_1x": "Church Bell 1x", + "amzn_sfx_church_bells_ringing": "Church Bells Ringing", + "amzn_sfx_clear_throat_ahem": "Clear Throat Ahem", + "amzn_sfx_clock_ticking": "Clock Ticking", + "amzn_sfx_clock_ticking_long": "Clock Ticking Long", + "amzn_sfx_copy_machine": "Copy Machine", + "amzn_sfx_cough": "Cough", + "amzn_sfx_crow_caw_1x": "Crow Caw 1x", + "amzn_sfx_crowd_applause": "Crowd Applause", + "amzn_sfx_crowd_bar": "Crowd Bar", + "amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy", + "amzn_sfx_crowd_boo": "Crowd Boo", + "amzn_sfx_crowd_cheer_med": "Crowd Cheer Med", + "amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer", + "amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x", + "amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x", + "amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl", + "amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x", + "amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x", + "amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x", + "amzn_sfx_door_open": "Door Open", + "amzn_sfx_door_shut": "Door Shut", + "amzn_sfx_doorbell": "Doorbell", + "amzn_sfx_doorbell_buzz": "Doorbell Buzz", + "amzn_sfx_doorbell_chime": "Doorbell Chime", + "amzn_sfx_drinking_slurp": "Drinking Slurp", + "amzn_sfx_drum_and_cymbal": "Drum And Cymbal", + "amzn_sfx_drum_comedy": "Drum Comedy", + "amzn_sfx_earthquake_rumble": "Earthquake Rumble", + "amzn_sfx_electric_guitar": "Electric Guitar", + "amzn_sfx_electronic_beep": "Electronic Beep", + "amzn_sfx_electronic_major_chord": "Electronic Major Chord", + "amzn_sfx_elephant": "Elephant", + "amzn_sfx_elevator_bell_1x": "Elevator Bell 1x", + "amzn_sfx_elevator_open_bell": "Elevator Open Bell", + "amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes", + "amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes", + "amzn_sfx_faucet_drip": "Faucet Drip", + "amzn_sfx_faucet_running": "Faucet Running", + "amzn_sfx_fireplace_crackle": "Fireplace Crackle", + "amzn_sfx_fireworks": "Fireworks", + "amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers", + "amzn_sfx_fireworks_launch": "Fireworks Launch", + "amzn_sfx_fireworks_whistles": "Fireworks Whistles", + "amzn_sfx_food_frying": "Food Frying", + "amzn_sfx_footsteps": "Footsteps", + "amzn_sfx_footsteps_muffled": "Footsteps Muffled", + "amzn_sfx_ghost_spooky": "Ghost Spooky", + "amzn_sfx_glass_on_table": "Glass On Table", + "amzn_sfx_glasses_clink": "Glasses Clink", + "amzn_sfx_horse_gallop_4x": "Horse Gallop 4x", + "amzn_sfx_horse_huff_whinny": "Horse Huff Whinny", + "amzn_sfx_horse_neigh": "Horse Neigh", + "amzn_sfx_horse_neigh_low": "Horse Neigh Low", + "amzn_sfx_horse_whinny": "Horse Whinny", + "amzn_sfx_human_walking": "Human Walking", + "amzn_sfx_jar_on_table_1x": "Jar On Table 1x", + "amzn_sfx_kitchen_ambience": "Kitchen Ambience", + "amzn_sfx_large_crowd_cheer": "Large Crowd Cheer", + "amzn_sfx_large_fire_crackling": "Large Fire Crackling", + "amzn_sfx_laughter": "Laughter", + "amzn_sfx_laughter_giggle": "Laughter Giggle", + "amzn_sfx_lightning_strike": "Lightning Strike", + "amzn_sfx_lion_roar": "Lion Roar", + "amzn_sfx_magic_blast_1x": "Magic Blast 1x", + "amzn_sfx_monkey_calls_3x": "Monkey Calls 3x", + "amzn_sfx_monkey_chimp": "Monkey Chimp", + "amzn_sfx_monkeys_chatter": "Monkeys Chatter", + "amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate", + "amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle", + "amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev", + "amzn_sfx_musical_drone_intro": "Musical Drone Intro", + "amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat", + "amzn_sfx_object_on_table_2x": "Object On Table 2x", + "amzn_sfx_ocean_wave_1x": "Ocean Wave 1x", + "amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x", + "amzn_sfx_ocean_wave_surf": "Ocean Wave Surf", + "amzn_sfx_people_walking": "People Walking", + "amzn_sfx_person_running": "Person Running", + "amzn_sfx_piano_note_1x": "Piano Note 1x", + "amzn_sfx_punch": "Punch", + "amzn_sfx_rain": "Rain", + "amzn_sfx_rain_on_roof": "Rain On Roof", + "amzn_sfx_rain_thunder": "Rain Thunder", + "amzn_sfx_rat_squeak_2x": "Rat Squeak 2x", + "amzn_sfx_rat_squeaks": "Rat Squeaks", + "amzn_sfx_raven_caw_1x": "Raven Caw 1x", + "amzn_sfx_raven_caw_2x": "Raven Caw 2x", + "amzn_sfx_restaurant_ambience": "Restaurant Ambience", + "amzn_sfx_rooster_crow": "Rooster Crow", + "amzn_sfx_scifi_air_escaping": "Scifi Air Escaping", + "amzn_sfx_scifi_alarm": "Scifi Alarm", + "amzn_sfx_scifi_alien_voice": "Scifi Alien Voice", + "amzn_sfx_scifi_boots_walking": "Scifi Boots Walking", + "amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion", + "amzn_sfx_scifi_door_open": "Scifi Door Open", + "amzn_sfx_scifi_engines_on": "Scifi Engines On", + "amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large", + "amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst", + "amzn_sfx_scifi_explosion": "Scifi Explosion", + "amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x", + "amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion", + "amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle", + "amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires", + "amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large", + "amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x", + "amzn_sfx_scifi_missile": "Scifi Missile", + "amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x", + "amzn_sfx_scifi_open_airlock": "Scifi Open Airlock", + "amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping", + "amzn_sfx_scifi_radar_low": "Scifi Radar Low", + "amzn_sfx_scifi_radar_medium": "Scifi Radar Medium", + "amzn_sfx_scifi_run_away": "Scifi Run Away", + "amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up", + "amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion", + "amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby", + "amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby", + "amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x", + "amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x", + "amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby", + "amzn_sfx_scifi_timer_beep": "Scifi Timer Beep", + "amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards", + "amzn_sfx_scifi_zap_electric": "Scifi Zap Electric", + "amzn_sfx_sheep_baa": "Sheep Baa", + "amzn_sfx_sheep_bleat": "Sheep Bleat", + "amzn_sfx_silverware_clank": "Silverware Clank", + "amzn_sfx_sirens": "Sirens", + "amzn_sfx_sleigh_bells": "Sleigh Bells", + "amzn_sfx_small_stream": "Small Stream", + "amzn_sfx_sneeze": "Sneeze", + "amzn_sfx_stream": "Stream", + "amzn_sfx_strong_wind_desert": "Strong Wind Desert", + "amzn_sfx_strong_wind_whistling": "Strong Wind Whistling", + "amzn_sfx_subway_leaving": "Subway Leaving", + "amzn_sfx_subway_passing": "Subway Passing", + "amzn_sfx_subway_stopping": "Subway Stopping", + "amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast", + "amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x", + "amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x", + "amzn_sfx_test_tone": "Test Tone", + "amzn_sfx_thunder_rumble": "Thunder Rumble", + "amzn_sfx_toilet_flush": "Toilet Flush", + "amzn_sfx_trumpet_bugle": "Trumpet Bugle", + "amzn_sfx_turkey_gobbling": "Turkey Gobbling", + "amzn_sfx_typing_medium": "Typing Medium", + "amzn_sfx_typing_short": "Typing Short", + "amzn_sfx_typing_typewriter": "Typing Typewriter", + "amzn_sfx_vacuum_off": "Vacuum Off", + "amzn_sfx_vacuum_on": "Vacuum On", + "amzn_sfx_walking_in_mud": "Walking In Mud", + "amzn_sfx_walking_in_snow": "Walking In Snow", + "amzn_sfx_walking_on_grass": "Walking On Grass", + "amzn_sfx_water_dripping": "Water Dripping", + "amzn_sfx_water_droplets": "Water Droplets", + "amzn_sfx_wind_strong_gusting": "Wind Strong Gusting", + "amzn_sfx_wind_whistling_desert": "Wind Whistling Desert", + "amzn_sfx_wings_flap_4x": "Wings Flap 4x", + "amzn_sfx_wings_flap_fast": "Wings Flap Fast", + "amzn_sfx_wolf_howl": "Wolf Howl", + "amzn_sfx_wolf_young_howl": "Wolf Young Howl", + "amzn_sfx_wooden_door": "Wooden Door", + "amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long", + "amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple", + "amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open", + "amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge", + "amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full", + "amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full", + "amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal", + "amzn_ui_sfx_gameshow_intro": "Gameshow Intro", + "amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response", + "amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response", + "amzn_ui_sfx_gameshow_outro": "Gameshow Outro", + "amzn_ui_sfx_gameshow_player1": "Gameshow Player1", + "amzn_ui_sfx_gameshow_player2": "Gameshow Player2", + "amzn_ui_sfx_gameshow_player3": "Gameshow Player3", + "amzn_ui_sfx_gameshow_player4": "Gameshow Player4", + "amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response", + "amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative", + "amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive", + "amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s", + "anchor": "Anchor", + "answering_machines": "Answering Machines", + "arcs_sparks": "Arcs Sparks", + "arrows_bows": "Arrows Bows", + "baby": "Baby", + "back_up_beeps": "Back Up Beeps", + "bars_restaurants": "Bars Restaurants", + "baseball": "Baseball", + "basketball": "Basketball", + "battles": "Battles", + "beeps_tones": "Beeps Tones", + "bell": "Bell", + "bikes": "Bikes", + "billiards": "Billiards", + "board_games": "Board Games", + "body": "Body", + "boing": "Boing", + "books": "Books", + "bow_wash": "Bow Wash", + "box": "Box", + "break_shatter_smash": "Break Shatter Smash", + "breaks": "Breaks", + "brooms_mops": "Brooms Mops", + "bullets": "Bullets", + "buses": "Buses", + "buzz": "Buzz", + "buzz_hums": "Buzz Hums", + "buzzers": "Buzzers", + "buzzers_pistols": "Buzzers Pistols", + "cables_metal": "Cables Metal", + "camera": "Camera", + "cannons": "Cannons", + "car_alarm": "Car Alarm", + "car_alarms": "Car Alarms", + "car_cell_phones": "Car Cell Phones", + "carnivals_fairs": "Carnivals Fairs", + "cars": "Cars", + "casino": "Casino", + "casinos": "Casinos", + "cellar": "Cellar", + "chimes": "Chimes", + "chimes_bells": "Chimes Bells", + "chorus": "Chorus", + "christmas": "Christmas", + "church_bells": "Church Bells", + "clock": "Clock", + "cloth": "Cloth", + "concrete": "Concrete", + "construction": "Construction", + "construction_factory": "Construction Factory", + "crashes": "Crashes", + "crowds": "Crowds", + "debris": "Debris", + "dining_kitchens": "Dining Kitchens", + "dinosaurs": "Dinosaurs", + "dripping": "Dripping", + "drops": "Drops", + "electric": "Electric", + "electrical": "Electrical", + "elevator": "Elevator", + "evolution_monsters": "Evolution Monsters", + "explosions": "Explosions", + "factory": "Factory", + "falls": "Falls", + "fax_scanner_copier": "Fax Scanner Copier", + "feedback_mics": "Feedback Mics", + "fight": "Fight", + "fire": "Fire", + "fire_extinguisher": "Fire Extinguisher", + "fireballs": "Fireballs", + "fireworks": "Fireworks", + "fishing_pole": "Fishing Pole", + "flags": "Flags", + "football": "Football", + "footsteps": "Footsteps", + "futuristic": "Futuristic", + "futuristic_ship": "Futuristic Ship", + "gameshow": "Gameshow", + "gear": "Gear", + "ghosts_demons": "Ghosts Demons", + "giant_monster": "Giant Monster", + "glass": "Glass", + "glasses_clink": "Glasses Clink", + "golf": "Golf", + "gorilla": "Gorilla", + "grenade_lanucher": "Grenade Lanucher", + "griffen": "Griffen", + "gyms_locker_rooms": "Gyms Locker Rooms", + "handgun_loading": "Handgun Loading", + "handgun_shot": "Handgun Shot", + "handle": "Handle", + "hands": "Hands", + "heartbeats_ekg": "Heartbeats EKG", + "helicopter": "Helicopter", + "high_tech": "High Tech", + "hit_punch_slap": "Hit Punch Slap", + "hits": "Hits", + "horns": "Horns", + "horror": "Horror", + "hot_tub_filling_up": "Hot Tub Filling Up", + "human": "Human", + "human_vocals": "Human Vocals", + "hygene": "Hygene", + "ice_skating": "Ice Skating", + "ignitions": "Ignitions", + "infantry": "Infantry", + "intro": "Intro", + "jet": "Jet", + "juggling": "Juggling", + "key_lock": "Key Lock", + "kids": "Kids", + "knocks": "Knocks", + "lab_equip": "Lab Equip", + "lacrosse": "Lacrosse", + "lamps_lanterns": "Lamps Lanterns", + "leather": "Leather", + "liquid_suction": "Liquid Suction", + "locker_doors": "Locker Doors", + "machine_gun": "Machine Gun", + "magic_spells": "Magic Spells", + "medium_large_explosions": "Medium Large Explosions", + "metal": "Metal", + "modern_rings": "Modern Rings", + "money_coins": "Money Coins", + "motorcycles": "Motorcycles", + "movement": "Movement", + "moves": "Moves", + "nature": "Nature", + "oar_boat": "Oar Boat", + "pagers": "Pagers", + "paintball": "Paintball", + "paper": "Paper", + "parachute": "Parachute", + "pay_phones": "Pay Phones", + "phone_beeps": "Phone Beeps", + "pigmy_bats": "Pigmy Bats", + "pills": "Pills", + "pour_water": "Pour Water", + "power_up_down": "Power Up Down", + "printers": "Printers", + "prison": "Prison", + "public_space": "Public Space", + "racquetball": "Racquetball", + "radios_static": "Radios Static", + "rain": "Rain", + "rc_airplane": "RC Airplane", + "rc_car": "RC Car", + "refrigerators_freezers": "Refrigerators Freezers", + "regular": "Regular", + "respirator": "Respirator", + "rifle": "Rifle", + "roller_coaster": "Roller Coaster", + "rollerskates_rollerblades": "RollerSkates RollerBlades", + "room_tones": "Room Tones", + "ropes_climbing": "Ropes Climbing", + "rotary_rings": "Rotary Rings", + "rowboat_canoe": "Rowboat Canoe", + "rubber": "Rubber", + "running": "Running", + "sails": "Sails", + "sand_gravel": "Sand Gravel", + "screen_doors": "Screen Doors", + "screens": "Screens", + "seats_stools": "Seats Stools", + "servos": "Servos", + "shoes_boots": "Shoes Boots", + "shotgun": "Shotgun", + "shower": "Shower", + "sink_faucet": "Sink Faucet", + "sink_filling_water": "Sink Filling Water", + "sink_run_and_off": "Sink Run And Off", + "sink_water_splatter": "Sink Water Splatter", + "sirens": "Sirens", + "skateboards": "Skateboards", + "ski": "Ski", + "skids_tires": "Skids Tires", + "sled": "Sled", + "slides": "Slides", + "small_explosions": "Small Explosions", + "snow": "Snow", + "snowmobile": "Snowmobile", + "soldiers": "Soldiers", + "splash_water": "Splash Water", + "splashes_sprays": "Splashes Sprays", + "sports_whistles": "Sports Whistles", + "squeaks": "Squeaks", + "squeaky": "Squeaky", + "stairs": "Stairs", + "steam": "Steam", + "submarine_diesel": "Submarine Diesel", + "swing_doors": "Swing Doors", + "switches_levers": "Switches Levers", + "swords": "Swords", + "tape": "Tape", + "tape_machine": "Tape Machine", + "televisions_shows": "Televisions Shows", + "tennis_pingpong": "Tennis PingPong", + "textile": "Textile", + "throw": "Throw", + "thunder": "Thunder", + "ticks": "Ticks", + "timer": "Timer", + "toilet_flush": "Toilet Flush", + "tone": "Tone", + "tones_noises": "Tones Noises", + "toys": "Toys", + "tractors": "Tractors", + "traffic": "Traffic", + "train": "Train", + "trucks_vans": "Trucks Vans", + "turnstiles": "Turnstiles", + "typing": "Typing", + "umbrella": "Umbrella", + "underwater": "Underwater", + "vampires": "Vampires", + "various": "Various", + "video_tunes": "Video Tunes", + "volcano_earthquake": "Volcano Earthquake", + "watches": "Watches", + "water": "Water", + "water_running": "Water Running", + "werewolves": "Werewolves", + "winches_gears": "Winches Gears", + "wind": "Wind", + "wood": "Wood", + "wood_boat": "Wood Boat", + "woosh": "Woosh", + "zap": "Zap", + "zippers": "Zippers" + } + } + }, "exceptions": { "cannot_connect_with_error": { "message": "Error connecting: {error}" }, "cannot_retrieve_data_with_error": { "message": "Error retrieving data: {error}" + }, + "device_serial_number_missing": { + "message": "Device serial number missing: {device_id}" + }, + "invalid_device_id": { + "message": "Invalid device ID specified: {device_id}" + }, + "invalid_sound_value": { + "message": "Invalid sound {sound} with variant {variant} specified" + }, + "entry_not_loaded": { + "message": "Entry not loaded: {entry}" } } } diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index a5a49a343a9..22596706862 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -69,6 +69,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type ) + client.send_sound_notification = AsyncMock() yield client diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 8a2f5b6b158..6a4dff1c38d 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -5,3 +5,5 @@ TEST_COUNTRY = "IT" TEST_PASSWORD = "fake_password" TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" + +TEST_DEVICE_ID = "echo_test_device_id" diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr new file mode 100644 index 00000000000..b95108b0d03 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_send_sound_service + _Call( + tuple( + dict({ + 'account_name': 'Echo Test', + 'appliance_id': 'G1234567890123456789012345678A', + 'bluetooth_state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device_cluster_members': list([ + 'echo_test_serial_number', + ]), + 'device_family': 'mine', + 'device_locale': 'en-US', + 'device_owner_customer_id': 'amazon_ower_id', + 'device_type': 'echo', + 'do_not_disturb': False, + 'entity_id': '11111111-2222-3333-4444-555555555555', + 'online': True, + 'response_style': None, + 'sensors': dict({ + 'temperature': dict({ + 'name': 'temperature', + 'scale': 'CELSIUS', + 'value': '22.5', + }), + }), + 'serial_number': 'echo_test_serial_number', + 'software_version': 'echo_test_software_version', + }), + 'chimes_bells_01', + ), + dict({ + }), + ) +# --- +# name: test_send_text_service + _Call( + tuple( + dict({ + 'account_name': 'Echo Test', + 'appliance_id': 'G1234567890123456789012345678A', + 'bluetooth_state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device_cluster_members': list([ + 'echo_test_serial_number', + ]), + 'device_family': 'mine', + 'device_locale': 'en-US', + 'device_owner_customer_id': 'amazon_ower_id', + 'device_type': 'echo', + 'do_not_disturb': False, + 'entity_id': '11111111-2222-3333-4444-555555555555', + 'online': True, + 'response_style': None, + 'sensors': dict({ + 'temperature': dict({ + 'name': 'temperature', + 'scale': 'CELSIUS', + 'value': '22.5', + }), + }), + 'serial_number': 'echo_test_serial_number', + 'software_version': 'echo_test_software_version', + }), + 'Play B.B.C. radio on TuneIn', + ), + dict({ + }), + ) +# --- diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py new file mode 100644 index 00000000000..914664199c2 --- /dev/null +++ b/tests/components/alexa_devices/test_services.py @@ -0,0 +1,195 @@ +"""Tests for Alexa Devices services.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.alexa_devices.services import ( + ATTR_SOUND, + ATTR_SOUND_VARIANT, + ATTR_TEXT_COMMAND, + SERVICE_SOUND_NOTIFICATION, + SERVICE_TEXT_COMMAND, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_DEVICE_ID, TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, mock_device_registry + + +async def test_setup_services( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup of Alexa Devices services.""" + await setup_integration(hass, mock_config_entry) + + assert (services := hass.services.async_services_for_domain(DOMAIN)) + assert SERVICE_TEXT_COMMAND in services + assert SERVICE_SOUND_NOTIFICATION in services + + +async def test_send_sound_service( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test send sound service.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: "chimes_bells", + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert mock_amazon_devices_client.call_alexa_sound.call_count == 1 + assert mock_amazon_devices_client.call_alexa_sound.call_args == snapshot + + +async def test_send_text_service( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test send text service.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_TEXT_COMMAND, + { + ATTR_TEXT_COMMAND: "Play B.B.C. radio on TuneIn", + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert mock_amazon_devices_client.call_alexa_text_command.call_count == 1 + assert mock_amazon_devices_client.call_alexa_text_command.call_args == snapshot + + +@pytest.mark.parametrize( + ("sound", "device_id", "translation_key", "translation_placeholders"), + [ + ( + "chimes_bells", + "fake_device_id", + "invalid_device_id", + {"device_id": "fake_device_id"}, + ), + ( + "wrong_sound_name", + TEST_DEVICE_ID, + "invalid_sound_value", + { + "sound": "wrong_sound_name", + "variant": "1", + }, + ), + ], +) +async def test_invalid_parameters( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + sound: str, + device_id: str, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test invalid service parameters.""" + + device_entry = dr.DeviceEntry( + id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) + + # Call Service + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: sound, + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry not loaded.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device_entry + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + # Call Service + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_SOUND_NOTIFICATION, + { + ATTR_SOUND: "chimes_bells", + ATTR_SOUND_VARIANT: 1, + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "entry_not_loaded" + assert exc_info.value.translation_placeholders == {"entry": mock_config_entry.title} From 69e3a5bc34aee464341d5a700829a422fc690578 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:02:37 +0800 Subject: [PATCH 0756/1118] Add support for more switchbot cloud vacuum models (#146637) --- .../components/switchbot_cloud/__init__.py | 5 +- .../components/switchbot_cloud/vacuum.py | 140 ++++- .../components/switchbot_cloud/test_vacuum.py | 522 ++++++++++++++++++ 3 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 tests/components/switchbot_cloud/test_vacuum.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 482c5c4a9e6..fef156e40db 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -142,12 +142,15 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type in [ "K10+", "K10+ Pro", "Robot Vacuum Cleaner S1", "Robot Vacuum Cleaner S1 Plus", + "K20+ Pro", + "Robot Vacuum Cleaner K10+ Pro Combo", + "Robot Vacuum Cleaner S10", + "S20", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id, True diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 9a9ad49626f..7bc4c7d0ea2 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -2,7 +2,15 @@ from typing import Any -from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands +from switchbot_api import ( + Device, + Remote, + SwitchBotAPI, + VacuumCleanerV2Commands, + VacuumCleanerV3Commands, + VacuumCleanMode, + VacuumCommands, +) from homeassistant.components.vacuum import ( StateVacuumEntity, @@ -63,6 +71,11 @@ VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = { class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): """Representation of a SwitchBot vacuum.""" + # "K10+" + # "K10+ Pro" + # "Robot Vacuum Cleaner S1" + # "Robot Vacuum Cleaner S1 Plus" + _attr_supported_features: VacuumEntityFeature = ( VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED @@ -85,23 +98,26 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): VacuumCommands.POW_LEVEL, parameters=VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed], ) - self.async_write_ha_state() + await self.coordinator.async_request_refresh() async def async_pause(self) -> None: """Pause the cleaning task.""" await self.send_api_command(VacuumCommands.STOP) + self.async_write_ha_state() async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" await self.send_api_command(VacuumCommands.DOCK) + await self.coordinator.async_request_refresh() async def async_start(self) -> None: """Start or resume the cleaning task.""" await self.send_api_command(VacuumCommands.START) + await self.coordinator.async_request_refresh() def _set_attributes(self) -> None: """Set attributes from coordinator data.""" - if not self.coordinator.data: + if self.coordinator.data is None: return self._attr_battery_level = self.coordinator.data.get("battery") @@ -109,11 +125,127 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): switchbot_state = str(self.coordinator.data.get("workingStatus")) self._attr_activity = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) + if self._attr_fan_speed is None: + self._attr_fan_speed = VACUUM_FAN_SPEED_QUIET + + +class SwitchBotCloudVacuumK20PlusPro(SwitchBotCloudVacuum): + """Representation of a SwitchBot K20+ Pro.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + await self.send_api_command( + VacuumCleanerV2Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1, + "waterLevel": 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + async def async_pause(self) -> None: + """Pause the cleaning task.""" + await self.send_api_command(VacuumCleanerV2Commands.PAUSE) + await self.coordinator.async_request_refresh() + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self.send_api_command(VacuumCleanerV2Commands.DOCK) + await self.coordinator.async_request_refresh() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + fan_level = ( + VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed) + if self.fan_speed + else None + ) + await self.send_api_command( + VacuumCleanerV2Commands.START_CLEAN, + parameters={ + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET) + + 1, + "times": 1, + }, + }, + ) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudVacuumK10PlusProCombo(SwitchBotCloudVacuumK20PlusPro): + """Representation of a SwitchBot vacuum K10+ Pro Combo.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + if fan_speed in VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: + await self.send_api_command( + VacuumCleanerV2Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + + 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + +class SwitchBotCloudVacuumV3(SwitchBotCloudVacuumK20PlusPro): + """Representation of a SwitchBot vacuum Robot Vacuum Cleaner S10 & S20.""" + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + await self.send_api_command( + VacuumCleanerV3Commands.CHANGE_PARAM, + parameters={ + "fanLevel": int(VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed]) + 1, + "waterLevel": 1, + "times": 1, + }, + ) + await self.coordinator.async_request_refresh() + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + fan_level = ( + VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.get(self.fan_speed) + if self.fan_speed + else None + ) + await self.send_api_command( + VacuumCleanerV3Commands.START_CLEAN, + parameters={ + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": int(fan_level if fan_level else VACUUM_FAN_SPEED_QUIET), + "waterLevel": 1, + "times": 1, + }, + }, + ) + await self.coordinator.async_request_refresh() @callback def _async_make_entity( api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator -) -> SwitchBotCloudVacuum: +) -> ( + SwitchBotCloudVacuum + | SwitchBotCloudVacuumK20PlusPro + | SwitchBotCloudVacuumV3 + | SwitchBotCloudVacuumK10PlusProCombo +): """Make a SwitchBotCloudVacuum.""" + if device.device_type in VacuumCleanerV2Commands.get_supported_devices(): + if device.device_type == "K20+ Pro": + return SwitchBotCloudVacuumK20PlusPro(api, device, coordinator) + return SwitchBotCloudVacuumK10PlusProCombo(api, device, coordinator) + + if device.device_type in VacuumCleanerV3Commands.get_supported_devices(): + return SwitchBotCloudVacuumV3(api, device, coordinator) return SwitchBotCloudVacuum(api, device, coordinator) diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py new file mode 100644 index 00000000000..daa52f4f183 --- /dev/null +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -0,0 +1,522 @@ +"""Test for the switchbot_cloud vacuum.""" + +from unittest.mock import patch + +from switchbot_api import ( + Device, + VacuumCleanerV2Commands, + VacuumCleanerV3Commands, + VacuumCleanMode, + VacuumCommands, +) + +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.components.switchbot_cloud.const import VACUUM_FAN_SPEED_QUIET +from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_k10_plus_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.POW_LEVEL, "command", "0" + ) + + +async def test_k10_plus_return_to_base( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10 plus return to base.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + } + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.DOCK, "command", "default" + ) + + +async def test_k10_plus_pause( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10 plus pause.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + } + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.STOP, "command", "default" + ) + + +async def test_k10_plus_set_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K10+", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K10+", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCommands.START, "command", "default" + ) + + +async def test_k20_plus_pro_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K10 plus set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "waterLevel": 1, + "times": 1, + }, + ) + + +async def test_k20_plus_pro_return_to_base( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro return to base.""" + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCleanerV2Commands.DOCK, "command", "default" + ) + + +async def test_k20_plus_pro_pause( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro pause.""" + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Charging", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + state = hass.states.get(entity_id) + + assert state.state == VacuumActivity.DOCKED.value + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, SERVICE_PAUSE, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", VacuumCleanerV2Commands.PAUSE, "command", "default" + ) + + +async def test_k20_plus_pro_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test K20+ Pro start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="K20+ Pro", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "K20+ Pro", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.START_CLEAN, + "command", + { + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": 1, + "times": 1, + }, + }, + ) + + +async def test_k10_plus_pro_combo_set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test k10+ Pro Combo set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="Robot Vacuum Cleaner K10+ Pro Combo", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "Robot Vacuum Cleaner K10+ Pro Combo", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV2Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "times": 1, + }, + ) + + +async def test_s20_start( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test s20 start.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="S20", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "s20", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_START, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV3Commands.START_CLEAN, + "command", + { + "action": VacuumCleanMode.SWEEP.value, + "param": { + "fanLevel": 0, + "waterLevel": 1, + "times": 1, + }, + }, + ) + + +async def test_s20set_fan_speed( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test s20 set fan speed.""" + + mock_list_devices.side_effect = [ + [ + Device( + version="V1.0", + deviceId="vacuum-id-1", + deviceName="vacuum-1", + deviceType="S20", + hubDeviceId="test-hub-id", + ) + ] + ] + mock_get_status.side_effect = [ + { + "deviceType": "S20", + "workingStatus": "Cleaning", + "battery": 50, + "onlineStatus": "online", + }, + ] + + await configure_integration(hass) + entity_id = "vacuum.vacuum_1" + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_SPEED: VACUUM_FAN_SPEED_QUIET}, + blocking=True, + ) + mock_send_command.assert_called_once_with( + "vacuum-id-1", + VacuumCleanerV3Commands.CHANGE_PARAM, + "command", + { + "fanLevel": 1, + "waterLevel": 1, + "times": 1, + }, + ) From 260ca707852433057b5743d1a91edb72eebf8ad0 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:03:13 +0800 Subject: [PATCH 0757/1118] Add Light platform to Switchbot cloud (#146382) --- .../components/switchbot_cloud/__init__.py | 13 + .../components/switchbot_cloud/const.py | 2 + .../components/switchbot_cloud/light.py | 153 +++++++++ tests/components/switchbot_cloud/conftest.py | 9 + .../components/switchbot_cloud/test_light.py | 300 ++++++++++++++++++ 5 files changed, 477 insertions(+) create mode 100644 homeassistant/components/switchbot_cloud/light.py create mode 100644 tests/components/switchbot_cloud/test_light.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index fef156e40db..ae3a32997ae 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -30,6 +30,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, Platform.FAN, + Platform.LIGHT, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -53,6 +54,7 @@ class SwitchbotDevices: vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -191,6 +193,17 @@ async def make_device_data( devices_data.fans.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Strip Light", + "Strip Light 3", + "Floor Lamp", + "Color Bulb", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.lights.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index b849194537a..dcca5119a74 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -15,3 +15,5 @@ VACUUM_FAN_SPEED_QUIET = "quiet" VACUUM_FAN_SPEED_STANDARD = "standard" VACUUM_FAN_SPEED_STRONG = "strong" VACUUM_FAN_SPEED_MAX = "max" + +AFTER_COMMAND_REFRESH = 5 diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py new file mode 100644 index 00000000000..645c6b4c62b --- /dev/null +++ b/homeassistant/components/switchbot_cloud/light.py @@ -0,0 +1,153 @@ +"""Support for the Switchbot Light.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + CommonCommands, + Device, + Remote, + RGBWLightCommands, + RGBWWLightCommands, + SwitchBotAPI, +) + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData, SwitchBotCoordinator +from .const import AFTER_COMMAND_REFRESH, DOMAIN +from .entity import SwitchBotCloudEntity + + +def value_map_brightness(value: int) -> int: + """Return value for brightness map.""" + return int(value / 255 * 100) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.lights + ) + + +class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity): + """Base Class for SwitchBot Light.""" + + _attr_is_on: bool | None = None + _attr_name: str | None = None + + _attr_color_mode = ColorMode.UNKNOWN + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str | None = self.coordinator.data.get("power") + brightness: int | None = self.coordinator.data.get("brightness") + color: str | None = self.coordinator.data.get("color") + color_temperature: int | None = self.coordinator.data.get("colorTemperature") + self._attr_is_on = power == "on" if power else None + self._attr_brightness: int | None = brightness if brightness else None + self._attr_rgb_color: tuple | None = ( + (tuple(int(i) for i in color.split(":"))) if color else None + ) + self._attr_color_temp_kelvin: int | None = ( + color_temperature if color_temperature else None + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + brightness: int | None = kwargs.get("brightness") + rgb_color: tuple[int, int, int] | None = kwargs.get("rgb_color") + color_temp_kelvin: int | None = kwargs.get("color_temp_kelvin") + if brightness is not None: + self._attr_color_mode = ColorMode.RGB + await self._send_brightness_command(brightness) + elif rgb_color is not None: + self._attr_color_mode = ColorMode.RGB + await self._send_rgb_color_command(rgb_color) + elif color_temp_kelvin is not None: + self._attr_color_mode = ColorMode.COLOR_TEMP + await self._send_color_temperature_command(color_temp_kelvin) + else: + self._attr_color_mode = ColorMode.RGB + await self.send_api_command(CommonCommands.ON) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def _send_brightness_command(self, brightness: int) -> None: + """Send a brightness command.""" + await self.send_api_command( + RGBWLightCommands.SET_BRIGHTNESS, + parameters=str(value_map_brightness(brightness)), + ) + + async def _send_rgb_color_command(self, rgb_color: tuple) -> None: + """Send an RGB command.""" + await self.send_api_command( + RGBWLightCommands.SET_COLOR, + parameters=f"{rgb_color[2]}:{rgb_color[1]}:{rgb_color[0]}", + ) + + async def _send_color_temperature_command(self, color_temp_kelvin: int) -> None: + """Send a color temperature command.""" + await self.send_api_command( + RGBWWLightCommands.SET_COLOR_TEMPERATURE, + parameters=str(color_temp_kelvin), + ) + + +class SwitchBotCloudStripLight(SwitchBotCloudLight): + """Representation of a SwitchBot Strip Light.""" + + _attr_supported_color_modes = {ColorMode.RGB} + + +class SwitchBotCloudRGBWWLight(SwitchBotCloudLight): + """Representation of SwitchBot |Strip Light|Floor Lamp|Color Bulb.""" + + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2700 + + _attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP} + + async def _send_brightness_command(self, brightness: int) -> None: + """Send a brightness command.""" + await self.send_api_command( + RGBWWLightCommands.SET_BRIGHTNESS, + parameters=str(value_map_brightness(brightness)), + ) + + async def _send_rgb_color_command(self, rgb_color: tuple) -> None: + """Send an RGB command.""" + await self.send_api_command( + RGBWWLightCommands.SET_COLOR, + parameters=f"{rgb_color[0]}:{rgb_color[1]}:{rgb_color[2]}", + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight: + """Make a SwitchBotCloudLight.""" + if device.device_type == "Strip Light": + return SwitchBotCloudStripLight(api, device, coordinator) + return SwitchBotCloudRGBWWLight(api, device, coordinator) diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 09c953da06b..27214fde28d 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -30,3 +30,12 @@ def mock_get_status(): """Mock get_status.""" with patch.object(SwitchBotAPI, "get_status") as mock_get_status: yield mock_get_status + + +@pytest.fixture(scope="package", autouse=True) +def mock_after_command_refresh(): + """Mock after command refresh.""" + with patch( + "homeassistant.components.switchbot_cloud.const.AFTER_COMMAND_REFRESH", 0 + ): + yield diff --git a/tests/components/switchbot_cloud/test_light.py b/tests/components/switchbot_cloud/test_light.py new file mode 100644 index 00000000000..e4f39c0d530 --- /dev/null +++ b/tests/components/switchbot_cloud/test_light.py @@ -0,0 +1,300 @@ +"""Test for the Switchbot Light Entity.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [None] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_UNKNOWN + + +async def test_strip_light_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test strip light turn off.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "off", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + # state = hass.states.get(entity_id) + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + + +async def test_rgbww_light_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test rgbww light turn_off.""" + + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "off", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + + with ( + patch.object(SwitchBotAPI, "send_command") as mock_send_command, + ): + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + + +async def test_strip_light_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test strip light turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "on", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "brightness": 99}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "rgb_color": (255, 246, 158)}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "color_temp_kelvin": 3333}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + +async def test_rgbww_light_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test rgbww light turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "on", "brightness": 10, "color": "0:0:0", "colorTemperature": 5555}, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + { + "power": "on", + "brightness": 10, + "color": "255:255:255", + "colorTemperature": 5555, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "color_temp_kelvin": 2800}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "brightness": 99}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "rgb_color": (255, 246, 158)}, + blocking=True, + ) + mock_send_command.assert_called() + state = hass.states.get(entity_id) + assert state.state is STATE_ON From 25169e9075628556324fd82aef9f38c61ea79cec Mon Sep 17 00:00:00 2001 From: Avery <130164016+avedor@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:06:38 -0400 Subject: [PATCH 0758/1118] Bump datadogpy to 0.52.0 (#149596) --- homeassistant/components/datadog/__init__.py | 4 +++- .../components/datadog/config_flow.py | 22 +++++++++++++++++-- homeassistant/components/datadog/const.py | 2 +- .../components/datadog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/datadog/test_init.py | 4 ++-- 7 files changed, 29 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index 606f34c9ae0..219f3afe4e2 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -75,7 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> b prefix = options[CONF_PREFIX] sample_rate = options[CONF_RATE] - statsd_client = DogStatsd(host=host, port=port, namespace=prefix) + statsd_client = DogStatsd( + host=host, port=port, namespace=prefix, disable_telemetry=True + ) entry.runtime_data = statsd_client initialize(statsd_host=host, statsd_port=port) diff --git a/homeassistant/components/datadog/config_flow.py b/homeassistant/components/datadog/config_flow.py index 876b79b6019..a2ad74e2c57 100644 --- a/homeassistant/components/datadog/config_flow.py +++ b/homeassistant/components/datadog/config_flow.py @@ -58,7 +58,6 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN): CONF_RATE: user_input[CONF_RATE], }, ) - return self.async_show_form( step_id="user", data_schema=vol.Schema( @@ -107,7 +106,26 @@ class DatadogOptionsFlowHandler(OptionsFlow): options = self.config_entry.options if user_input is None: - user_input = {} + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_PREFIX, + default=options.get( + CONF_PREFIX, data.get(CONF_PREFIX, DEFAULT_PREFIX) + ), + ): str, + vol.Required( + CONF_RATE, + default=options.get( + CONF_RATE, data.get(CONF_RATE, DEFAULT_RATE) + ), + ): int, + } + ), + errors={}, + ) success = await validate_datadog_connection( self.hass, diff --git a/homeassistant/components/datadog/const.py b/homeassistant/components/datadog/const.py index e9e5d80eeba..7c9a0311228 100644 --- a/homeassistant/components/datadog/const.py +++ b/homeassistant/components/datadog/const.py @@ -4,7 +4,7 @@ DOMAIN = "datadog" CONF_RATE = "rate" -DEFAULT_HOST = "localhost" +DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8125 DEFAULT_PREFIX = "hass" DEFAULT_RATE = 1 diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index 815446b9ab4..798a314e307 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["datadog"], "quality_scale": "legacy", - "requirements": ["datadog==0.15.0"] + "requirements": ["datadog==0.52.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93663598733..8c68449f7d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -759,7 +759,7 @@ crownstone-sse==2.0.5 crownstone-uart==2.1.0 # homeassistant.components.datadog -datadog==0.15.0 +datadog==0.52.0 # homeassistant.components.metoffice datapoint==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 268d263220a..0f2cf2c491e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -662,7 +662,7 @@ crownstone-sse==2.0.5 crownstone-uart==2.1.0 # homeassistant.components.datadog -datadog==0.15.0 +datadog==0.52.0 # homeassistant.components.metoffice datapoint==0.12.1 diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 3c22aaeee8f..7ab9e0cb97a 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -46,7 +46,7 @@ async def test_datadog_setup_full(hass: HomeAssistant) -> None: assert mock_dogstatsd.call_count == 1 assert mock_dogstatsd.call_args == mock.call( - host="host", port=123, namespace="foo" + host="host", port=123, namespace="foo", disable_telemetry=True ) @@ -65,7 +65,7 @@ async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: assert mock_dogstatsd.call_count == 1 assert mock_dogstatsd.call_args == mock.call( - host="localhost", port=8125, namespace="hass" + host="localhost", port=8125, namespace="hass", disable_telemetry=True ) From d8016f7f41f5b69851b9e2a586dad8c022a7a9bf Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:06:59 +0200 Subject: [PATCH 0759/1118] Remove stale devices in Uptime Kuma (#149605) --- .../components/uptime_kuma/__init__.py | 24 +++++- tests/components/uptime_kuma/test_init.py | 85 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 68234077976..4efe6a68193 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -6,7 +6,7 @@ from pythonkuma.update import UpdateChecker from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.hass_dict import HassKey @@ -43,6 +43,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) - return True +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a stale device from a config entry.""" + + def normalize_key(id: str) -> int | str: + key = id.removeprefix(f"{config_entry.entry_id}_") + return int(key) if key.isnumeric() else key + + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + and ( + identifier[1] == config_entry.entry_id + or normalize_key(identifier[1]) in config_entry.runtime_data.data + ) + ) + + async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py index 6e2ef43b14d..61d196f0263 100644 --- a/tests/components/uptime_kuma/test_init.py +++ b/tests/components/uptime_kuma/test_init.py @@ -8,8 +8,11 @@ from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException from homeassistant.components.uptime_kuma.const 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.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.mark.usefixtures("mock_pythonkuma") @@ -77,3 +80,85 @@ async def test_config_reauth_flow( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_stale_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can remove a device that is not in the coordinator data.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "123456789_1")} + ) + + config_entry.runtime_data.data.pop(1) + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, "123456789_1")}) is None + ) + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_current_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we cannot remove a device if it is still active.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "123456789_1")} + ) + + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] is False + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456789_1")}) + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_remove_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we cannot remove the device with the update entity.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "123456789")}) + + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + + assert response["success"] is False + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456789")}) From 779f0afcc452ae49628fd604fc45199030bb9a77 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:07:22 +0200 Subject: [PATCH 0760/1118] Refactor Habitica button and switch functions to use habiticalib instance directly (#149602) --- homeassistant/components/habitica/button.py | 103 ++++-------------- .../components/habitica/coordinator.py | 12 +- homeassistant/components/habitica/switch.py | 16 ++- 3 files changed, 37 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index c57ba39fb6a..de8920deb77 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -7,15 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from aiohttp import ClientError -from habiticalib import ( - HabiticaClass, - HabiticaException, - NotAuthorizedError, - Skill, - TaskType, - TooManyRequestsError, -) +from habiticalib import Habitica, HabiticaClass, Skill, TaskType from homeassistant.components.button import ( DOMAIN as BUTTON_DOMAIN, @@ -23,16 +15,11 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ASSETS_URL, DOMAIN -from .coordinator import ( - HabiticaConfigEntry, - HabiticaData, - HabiticaDataUpdateCoordinator, -) +from .coordinator import HabiticaConfigEntry, HabiticaData from .entity import HabiticaBase PARALLEL_UPDATES = 1 @@ -42,7 +29,7 @@ PARALLEL_UPDATES = 1 class HabiticaButtonEntityDescription(ButtonEntityDescription): """Describes Habitica button entity.""" - press_fn: Callable[[HabiticaDataUpdateCoordinator], Any] + press_fn: Callable[[Habitica], Any] available_fn: Callable[[HabiticaData], bool] class_needed: HabiticaClass | None = None entity_picture: str | None = None @@ -73,13 +60,13 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.RUN_CRON, translation_key=HabiticaButtonEntity.RUN_CRON, - press_fn=lambda coordinator: coordinator.habitica.run_cron(), + press_fn=lambda habitica: habitica.run_cron(), available_fn=lambda data: data.user.needsCron is True, ), HabiticaButtonEntityDescription( key=HabiticaButtonEntity.BUY_HEALTH_POTION, translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION, - press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(), + press_fn=lambda habitica: habitica.buy_health_potion(), available_fn=( lambda data: (data.user.stats.gp or 0) >= 25 and (data.user.stats.hp or 0) < 50 @@ -89,7 +76,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS, - press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(), + press_fn=lambda habitica: habitica.allocate_stat_points(), available_fn=( lambda data: data.user.preferences.automaticAllocation is True and (data.user.stats.points or 0) > 0 @@ -98,7 +85,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.REVIVE, translation_key=HabiticaButtonEntity.REVIVE, - press_fn=lambda coordinator: coordinator.habitica.revive(), + press_fn=lambda habitica: habitica.revive(), available_fn=lambda data: data.user.stats.hp == 0, ), ) @@ -108,9 +95,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.MPHEAL, translation_key=HabiticaButtonEntity.MPHEAL, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.ETHEREAL_SURGE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 30 @@ -121,7 +106,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.EARTH, translation_key=HabiticaButtonEntity.EARTH, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE), + press_fn=lambda habitica: habitica.cast_skill(Skill.EARTHQUAKE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 35 @@ -132,9 +117,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.FROST, translation_key=HabiticaButtonEntity.FROST, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.CHILLING_FROST), # chilling frost can only be cast once per day (streaks buff is false) available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 @@ -147,9 +130,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.DEFENSIVE_STANCE, translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.DEFENSIVE_STANCE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 25 @@ -160,9 +141,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.VALOROUS_PRESENCE, translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.VALOROUS_PRESENCE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 20 @@ -173,9 +152,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.INTIMIDATE, translation_key=HabiticaButtonEntity.INTIMIDATE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.INTIMIDATING_GAZE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 and (data.user.stats.mp or 0) >= 15 @@ -186,11 +163,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.TOOLS_OF_TRADE, translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill( - Skill.TOOLS_OF_THE_TRADE - ) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.TOOLS_OF_THE_TRADE), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 25 @@ -201,7 +174,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.STEALTH, translation_key=HabiticaButtonEntity.STEALTH, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH), + press_fn=lambda habitica: habitica.cast_skill(Skill.STEALTH), # Stealth buffs stack and it can only be cast if the amount of # buffs is smaller than the amount of unfinished dailies available_fn=( @@ -224,9 +197,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.HEAL, translation_key=HabiticaButtonEntity.HEAL, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.HEALING_LIGHT), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 11 and (data.user.stats.mp or 0) >= 15 @@ -238,11 +209,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.BRIGHTNESS, translation_key=HabiticaButtonEntity.BRIGHTNESS, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill( - Skill.SEARING_BRIGHTNESS - ) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.SEARING_BRIGHTNESS), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 12 and (data.user.stats.mp or 0) >= 15 @@ -253,9 +220,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.PROTECT_AURA, translation_key=HabiticaButtonEntity.PROTECT_AURA, - press_fn=( - lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA) - ), + press_fn=lambda habitica: habitica.cast_skill(Skill.PROTECTIVE_AURA), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 13 and (data.user.stats.mp or 0) >= 30 @@ -266,7 +231,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( HabiticaButtonEntityDescription( key=HabiticaButtonEntity.HEAL_ALL, translation_key=HabiticaButtonEntity.HEAL_ALL, - press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING), + press_fn=lambda habitica: habitica.cast_skill(Skill.BLESSING), available_fn=( lambda data: (data.user.stats.lvl or 0) >= 14 and (data.user.stats.mp or 0) >= 25 @@ -332,33 +297,9 @@ class HabiticaButton(HabiticaBase, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - try: - await self.entity_description.press_fn(self.coordinator) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_call_unallowed", - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": e.error.message}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - await self.coordinator.async_request_refresh() + + await self.coordinator.execute(self.entity_description.press_fn) + await self.coordinator.async_request_refresh() @property def available(self) -> bool: diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index b25edc7ceaf..0e0a2db8d58 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + ServiceValidationError, ) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -130,19 +131,22 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): else: return HabiticaData(user=user, tasks=tasks + completed_todos) - async def execute( - self, func: Callable[[HabiticaDataUpdateCoordinator], Any] - ) -> None: + async def execute(self, func: Callable[[Habitica], Any]) -> None: """Execute an API call.""" try: - await func(self) + await func(self.habitica) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="setup_rate_limit_exception", translation_placeholders={"retry_after": str(e.retry_after)}, ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_call_unallowed", + ) from e except HabiticaException as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index fb98460f7e5..826cd341bba 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any +from habiticalib import Habitica + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -15,11 +17,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ( - HabiticaConfigEntry, - HabiticaData, - HabiticaDataUpdateCoordinator, -) +from .coordinator import HabiticaConfigEntry, HabiticaData from .entity import HabiticaBase PARALLEL_UPDATES = 1 @@ -29,8 +27,8 @@ PARALLEL_UPDATES = 1 class HabiticaSwitchEntityDescription(SwitchEntityDescription): """Describes Habitica switch entity.""" - turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any] - turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any] + turn_on_fn: Callable[[Habitica], Any] + turn_off_fn: Callable[[Habitica], Any] is_on_fn: Callable[[HabiticaData], bool | None] @@ -45,8 +43,8 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = ( key=HabiticaSwitchEntity.SLEEP, translation_key=HabiticaSwitchEntity.SLEEP, device_class=SwitchDeviceClass.SWITCH, - turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), - turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(), + turn_on_fn=lambda habitica: habitica.toggle_sleep(), + turn_off_fn=lambda habitica: habitica.toggle_sleep(), is_on_fn=lambda data: data.user.preferences.sleep, ), ) From dd0b23afb06b939315587fdde63739d0b622bdc2 Mon Sep 17 00:00:00 2001 From: Alistair Francis Date: Wed, 30 Jul 2025 23:07:47 +1000 Subject: [PATCH 0761/1118] husqvarna_automower_ble: Support battery percentage sensor (#146159) Signed-off-by: Alistair Francis --- .../husqvarna_automower_ble/__init__.py | 1 + .../husqvarna_automower_ble/coordinator.py | 6 +-- .../husqvarna_automower_ble/entity.py | 16 ++++++ .../husqvarna_automower_ble/sensor.py | 51 ++++++++++++++++++ .../snapshots/test_sensor.ambr | 54 +++++++++++++++++++ .../husqvarna_automower_ble/test_sensor.py | 32 +++++++++++ 6 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower_ble/sensor.py create mode 100644 tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr create mode 100644 tests/components/husqvarna_automower_ble/test_sensor.py diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index f168e84be4c..fd4521549a2 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -19,6 +19,7 @@ type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] PLATFORMS = [ Platform.LAWN_MOWER, + Platform.SENSOR, ] diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index c7781becd76..ef9ccfa5a47 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: SCAN_INTERVAL = timedelta(seconds=60) -class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): +class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]): """Class to manage fetching data.""" def __init__( @@ -67,11 +67,11 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): except BleakError as err: raise UpdateFailed("Failed to connect") from err - async def _async_update_data(self) -> dict[str, bytes]: + async def _async_update_data(self) -> dict[str, str | int]: """Poll the device.""" LOGGER.debug("Polling device") - data: dict[str, bytes] = {} + data: dict[str, str | int] = {} try: if not self.mower.is_connected(): diff --git a/homeassistant/components/husqvarna_automower_ble/entity.py b/homeassistant/components/husqvarna_automower_ble/entity.py index d2873d933ff..cb62f36027a 100644 --- a/homeassistant/components/husqvarna_automower_ble/entity.py +++ b/homeassistant/components/husqvarna_automower_ble/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -28,3 +29,18 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]): def available(self) -> bool: """Return if entity is available.""" return super().available and self.coordinator.mower.is_connected() + + +class HusqvarnaAutomowerBleDescriptorEntity(HusqvarnaAutomowerBleEntity): + """Coordinator entity for entities with entity description.""" + + def __init__( + self, coordinator: HusqvarnaCoordinator, description: EntityDescription + ) -> None: + """Initialize description entity.""" + super().__init__(coordinator) + + self._attr_unique_id = ( + f"{coordinator.address}_{coordinator.channel_id}_{description.key}" + ) + self.entity_description = description diff --git a/homeassistant/components/husqvarna_automower_ble/sensor.py b/homeassistant/components/husqvarna_automower_ble/sensor.py new file mode 100644 index 00000000000..f747133c950 --- /dev/null +++ b/homeassistant/components/husqvarna_automower_ble/sensor.py @@ -0,0 +1,51 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HusqvarnaConfigEntry +from .entity import HusqvarnaAutomowerBleDescriptorEntity + +DESCRIPTIONS = ( + SensorEntityDescription( + key="battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HusqvarnaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Husqvarna Automower Ble sensor based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HusqvarnaAutomowerBleSensor(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class HusqvarnaAutomowerBleSensor(HusqvarnaAutomowerBleDescriptorEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: SensorEntityDescription + + @property + def native_value(self) -> str | int: + """Return the previously fetched value.""" + return self.coordinator.data[self.entity_description.key] diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8f2bfadf56a --- /dev/null +++ b/tests/components/husqvarna_automower_ble/snapshots/test_sensor.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_setup[sensor.husqvarna_automower_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.husqvarna_automower_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': 'husqvarna_automower_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000003_1197489078_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[sensor.husqvarna_automower_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Husqvarna AutoMower Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.husqvarna_automower_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/husqvarna_automower_ble/test_sensor.py b/tests/components/husqvarna_automower_ble/test_sensor.py new file mode 100644 index 00000000000..d1f0a13cc43 --- /dev/null +++ b/tests/components/husqvarna_automower_ble/test_sensor.py @@ -0,0 +1,32 @@ +"""Test the Husqvarna Automower Bluetooth setup.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("mock_automower_client") + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup creates expected entities.""" + + with patch( + "homeassistant.components.husqvarna_automower_ble.PLATFORMS", [Platform.SENSOR] + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From ba4e7e50e0095cc6a499e3a16ca39475dd1b318a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:10:30 +0200 Subject: [PATCH 0762/1118] Add friend tracking to PlayStation Network (#149546) --- .../playstation_network/__init__.py | 22 +- .../playstation_network/config_flow.py | 94 ++++++- .../components/playstation_network/const.py | 1 + .../playstation_network/coordinator.py | 91 ++++++- .../components/playstation_network/entity.py | 24 +- .../components/playstation_network/helpers.py | 18 +- .../components/playstation_network/icons.json | 7 + .../components/playstation_network/image.py | 60 +++- .../components/playstation_network/sensor.py | 62 ++++- .../playstation_network/strings.json | 41 +++ .../playstation_network/conftest.py | 23 +- .../snapshots/test_diagnostics.ambr | 1 - .../snapshots/test_sensor.ambr | 256 ++++++++++++++++++ .../playstation_network/test_config_flow.py | 172 +++++++++++- .../playstation_network/test_init.py | 81 ++++++ 15 files changed, 920 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index bfa9de5d5cb..c2399c61f93 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_NPSSO from .coordinator import ( PlaystationNetworkConfigEntry, + PlaystationNetworkFriendDataCoordinator, PlaystationNetworkGroupsUpdateCoordinator, PlaystationNetworkRuntimeData, PlaystationNetworkTrophyTitlesCoordinator, @@ -39,14 +40,33 @@ async def async_setup_entry( groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry) await groups.async_config_entry_first_refresh() + friends = {} + + for subentry_id, subentry in entry.subentries.items(): + friend_coordinator = PlaystationNetworkFriendDataCoordinator( + hass, psn, entry, subentry + ) + await friend_coordinator.async_config_entry_first_refresh() + friends[subentry_id] = friend_coordinator + entry.runtime_data = PlaystationNetworkRuntimeData( - coordinator, trophy_titles, groups + coordinator, trophy_titles, groups, friends ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry( hass: HomeAssistant, entry: PlaystationNetworkConfigEntry ) -> bool: diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index 0e69abf1080..d4822225c61 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -10,13 +10,28 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) +from psnawp_api.models import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK +from .const import CONF_ACCOUNT_ID, CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK +from .coordinator import PlaystationNetworkConfigEntry from .helpers import PlaystationNetwork _LOGGER = logging.getLogger(__name__) @@ -27,6 +42,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str}) class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Playstation Network.""" + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"friend": FriendSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -54,6 +77,15 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(user.account_id) self._abort_if_unique_id_configured() + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if user.account_id in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort( + reason="already_configured_as_subentry" + ) + return self.async_create_entry( title=user.online_id, data={CONF_NPSSO: npsso}, @@ -132,3 +164,61 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): "psn_link": PSN_LINK, }, ) + + +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + + friends_list: dict[str, User] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + config_entry: PlaystationNetworkConfigEntry = self._get_entry() + + if user_input is not None: + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_ACCOUNT_ID] in { + entry.unique_id for entry in config_entries + }: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_ACCOUNT_ID] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=self.friends_list[user_input[CONF_ACCOUNT_ID]].online_id, + data={}, + unique_id=user_input[CONF_ACCOUNT_ID], + ) + + self.friends_list = await self.hass.async_add_executor_job( + lambda: { + friend.account_id: friend + for friend in config_entry.runtime_data.user_data.psn.user.friends_list() + } + ) + + options = [ + SelectOptionDict( + value=friend.account_id, + label=friend.online_id, + ) + for friend in self.friends_list.values() + ] + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ACCOUNT_ID): SelectSelector( + SelectSelectorConfig(options=options) + ) + } + ), + user_input, + ), + ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index f4c5c7a3e5b..df553a2ec01 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -6,6 +6,7 @@ from psnawp_api.models.trophies import PlatformType DOMAIN = "playstation_network" CONF_NPSSO: Final = "npsso" +CONF_ACCOUNT_ID: Final = "account_id" SUPPORTED_PLATFORMS = { PlatformType.PS_VITA, diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 19153d1bb01..c447e8dc503 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -6,21 +6,30 @@ from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from psnawp_api.core.psnawp_exceptions import ( PSNAWPAuthenticationError, PSNAWPClientError, + PSNAWPError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, PSNAWPServerError, ) +from psnawp_api.models import User from psnawp_api.models.group.group_datatypes import GroupDetails from psnawp_api.models.trophies import TrophyTitle -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import CONF_ACCOUNT_ID, DOMAIN from .helpers import PlaystationNetwork, PlaystationNetworkData _LOGGER = logging.getLogger(__name__) @@ -35,6 +44,7 @@ class PlaystationNetworkRuntimeData: user_data: PlaystationNetworkUserDataCoordinator trophy_titles: PlaystationNetworkTrophyTitlesCoordinator groups: PlaystationNetworkGroupsUpdateCoordinator + friends: dict[str, PlaystationNetworkFriendDataCoordinator] class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -140,3 +150,78 @@ class PlaystationNetworkGroupsUpdateCoordinator( if not group_info.group_id.startswith("~") } ) + + +class PlaystationNetworkFriendDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Friend status data update coordinator for PSN.""" + + user: User + profile: dict[str, Any] + + def __init__( + self, + hass: HomeAssistant, + psn: PlaystationNetwork, + config_entry: PlaystationNetworkConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the Coordinator.""" + self._update_interval = timedelta( + seconds=max(9 * len(config_entry.subentries), 180) + ) + super().__init__(hass, psn, config_entry) + self.subentry = subentry + + def _setup(self) -> None: + """Set up the coordinator.""" + self.user = self.psn.psn.user(account_id=self.subentry.data[CONF_ACCOUNT_ID]) + self.profile = self.user.profile() + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + await self.hass.async_add_executor_job(self._setup) + except PSNAWPNotFoundError as error: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="user_not_found", + translation_placeholders={"user": self.subentry.title}, + ) from error + + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + + except (PSNAWPServerError, PSNAWPClientError) as error: + _LOGGER.debug("Update failed", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + + def _update_data(self) -> PlaystationNetworkData: + """Update friend status data.""" + try: + return PlaystationNetworkData( + username=self.user.online_id, + account_id=self.user.account_id, + presence=self.user.get_presence(), + profile=self.profile, + ) + except PSNAWPForbiddenError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="user_profile_private", + translation_placeholders={"user": self.subentry.title}, + ) from error + except PSNAWPError: + raise + + async def update_data(self) -> PlaystationNetworkData: + """Update friend status data.""" + return await self.hass.async_add_executor_job(self._update_data) diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index ad7c52bdb39..dc1f126505c 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -2,12 +2,14 @@ from typing import TYPE_CHECKING +from homeassistant.config_entries import ConfigSubentry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PlayStationNetworkBaseCoordinator +from .helpers import PlaystationNetworkData class PlaystationNetworkServiceEntity( @@ -21,18 +23,32 @@ class PlaystationNetworkServiceEntity( self, coordinator: PlayStationNetworkBaseCoordinator, entity_description: EntityDescription, + subentry: ConfigSubentry | None = None, ) -> None: """Initialize PlayStation Network Service Entity.""" super().__init__(coordinator) if TYPE_CHECKING: assert coordinator.config_entry.unique_id self.entity_description = entity_description - self._attr_unique_id = ( - f"{coordinator.config_entry.unique_id}_{entity_description.key}" + self.subentry = subentry + unique_id = ( + subentry.unique_id + if subentry is not None and subentry.unique_id + else coordinator.config_entry.unique_id ) + + self._attr_unique_id = f"{unique_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, - name=coordinator.psn.user.online_id, + identifiers={(DOMAIN, unique_id)}, + name=( + coordinator.data.username + if isinstance(coordinator.data, PlaystationNetworkData) + else coordinator.psn.user.online_id + ), entry_type=DeviceEntryType.SERVICE, manufacturer="Sony Interactive Entertainment", ) + if subentry: + self._attr_device_info.update( + DeviceInfo(via_device=(DOMAIN, coordinator.config_entry.unique_id)) + ) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 9960d8afd79..492a011cf78 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -38,7 +38,6 @@ class PlaystationNetworkData: presence: dict[str, Any] = field(default_factory=dict) username: str = "" account_id: str = "" - availability: str = "unavailable" active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) registered_platforms: set[PlatformType] = field(default_factory=set) trophy_summary: TrophySummary | None = None @@ -61,6 +60,7 @@ class PlaystationNetwork: self.legacy_profile: dict[str, Any] | None = None self.trophy_titles: list[TrophyTitle] = [] self._title_icon_urls: dict[str, str] = {} + self.friends_list: dict[str, User] | None = None def _setup(self) -> None: """Setup PSN.""" @@ -97,6 +97,7 @@ class PlaystationNetwork: # check legacy platforms if owned if LEGACY_PLATFORMS & data.registered_platforms: self.legacy_profile = self.client.get_profile_legacy() + return data async def get_data(self) -> PlaystationNetworkData: @@ -105,7 +106,6 @@ class PlaystationNetwork: data.username = self.user.online_id data.account_id = self.user.account_id data.shareable_profile_link = self.shareable_profile_link - data.availability = data.presence["basicPresence"]["availability"] if "platform" in data.presence["basicPresence"]["primaryPlatformInfo"]: primary_platform = PlatformType( @@ -193,3 +193,17 @@ class PlaystationNetwork: def normalize_title(name: str) -> str: """Normalize trophy title.""" return name.removesuffix("Trophies").removesuffix("Trophy Set").strip() + + +def get_game_title_info(presence: dict[str, Any]) -> dict[str, Any]: + """Retrieve title info from presence.""" + + return ( + next((title for title in game_title_info), {}) + if ( + game_title_info := presence.get("basicPresence", {}).get( + "gameTitleInfoList" + ) + ) + else {} + ) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index af2236bd126..5997f43fb5c 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -42,6 +42,13 @@ "availabletocommunicate": "mdi:cellphone", "offline": "mdi:account-off-outline" } + }, + "now_playing": { + "default": "mdi:controller", + "state": { + "unknown": "mdi:controller-off", + "unavailable": "mdi:controller-off" + } } }, "image": { diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py index b0195002c66..0a8e5daed62 100644 --- a/homeassistant/components/playstation_network/image.py +++ b/homeassistant/components/playstation_network/image.py @@ -5,18 +5,23 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum +from typing import TYPE_CHECKING from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import ( + PlayStationNetworkBaseCoordinator, PlaystationNetworkConfigEntry, PlaystationNetworkData, + PlaystationNetworkFriendDataCoordinator, PlaystationNetworkUserDataCoordinator, ) from .entity import PlaystationNetworkServiceEntity +from .helpers import get_game_title_info PARALLEL_UPDATES = 0 @@ -26,6 +31,7 @@ class PlaystationNetworkImage(StrEnum): AVATAR = "avatar" SHARE_PROFILE = "share_profile" + NOW_PLAYING_IMAGE = "now_playing_image" @dataclass(kw_only=True, frozen=True) @@ -35,12 +41,14 @@ class PlaystationNetworkImageEntityDescription(ImageEntityDescription): image_url_fn: Callable[[PlaystationNetworkData], str | None] -IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = ( +IMAGE_DESCRIPTIONS_ME: tuple[PlaystationNetworkImageEntityDescription, ...] = ( PlaystationNetworkImageEntityDescription( key=PlaystationNetworkImage.SHARE_PROFILE, translation_key=PlaystationNetworkImage.SHARE_PROFILE, image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"], ), +) +IMAGE_DESCRIPTIONS_ALL: tuple[PlaystationNetworkImageEntityDescription, ...] = ( PlaystationNetworkImageEntityDescription( key=PlaystationNetworkImage.AVATAR, translation_key=PlaystationNetworkImage.AVATAR, @@ -55,6 +63,14 @@ IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = ( ) ), ), + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.NOW_PLAYING_IMAGE, + translation_key=PlaystationNetworkImage.NOW_PLAYING_IMAGE, + image_url_fn=( + lambda data: get_game_title_info(data.presence).get("conceptIconUrl") + or get_game_title_info(data.presence).get("npTitleIconUrl") + ), + ), ) @@ -70,25 +86,43 @@ async def async_setup_entry( async_add_entities( [ PlaystationNetworkImageEntity(hass, coordinator, description) - for description in IMAGE_DESCRIPTIONS + for description in IMAGE_DESCRIPTIONS_ME + IMAGE_DESCRIPTIONS_ALL ] ) + for ( + subentry_id, + friend_data_coordinator, + ) in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkFriendImageEntity( + hass, + friend_data_coordinator, + description, + config_entry.subentries[subentry_id], + ) + for description in IMAGE_DESCRIPTIONS_ALL + ], + config_subentry_id=subentry_id, + ) -class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity): + +class PlaystationNetworkImageBaseEntity(PlaystationNetworkServiceEntity, ImageEntity): """An image entity.""" entity_description: PlaystationNetworkImageEntityDescription - coordinator: PlaystationNetworkUserDataCoordinator + coordinator: PlayStationNetworkBaseCoordinator def __init__( self, hass: HomeAssistant, - coordinator: PlaystationNetworkUserDataCoordinator, + coordinator: PlayStationNetworkBaseCoordinator, entity_description: PlaystationNetworkImageEntityDescription, + subentry: ConfigSubentry | None = None, ) -> None: """Initialize the image entity.""" - super().__init__(coordinator, entity_description) + super().__init__(coordinator, entity_description, subentry) ImageEntity.__init__(self, hass) self._attr_image_url = self.entity_description.image_url_fn(coordinator.data) @@ -96,6 +130,8 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + if TYPE_CHECKING: + assert isinstance(self.coordinator.data, PlaystationNetworkData) url = self.entity_description.image_url_fn(self.coordinator.data) if url != self._attr_image_url: @@ -104,3 +140,15 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity self._attr_image_last_updated = dt_util.utcnow() super()._handle_coordinator_update() + + +class PlaystationNetworkImageEntity(PlaystationNetworkImageBaseEntity): + """An image entity.""" + + coordinator: PlaystationNetworkUserDataCoordinator + + +class PlaystationNetworkFriendImageEntity(PlaystationNetworkImageBaseEntity): + """An image entity.""" + + coordinator: PlaystationNetworkFriendDataCoordinator diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index 63cca074c3e..16d1ff13906 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -19,11 +19,14 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .coordinator import ( + PlayStationNetworkBaseCoordinator, PlaystationNetworkConfigEntry, PlaystationNetworkData, + PlaystationNetworkFriendDataCoordinator, PlaystationNetworkUserDataCoordinator, ) from .entity import PlaystationNetworkServiceEntity +from .helpers import get_game_title_info PARALLEL_UPDATES = 0 @@ -33,7 +36,6 @@ class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): """PlayStation Network sensor description.""" value_fn: Callable[[PlaystationNetworkData], StateType | datetime] - entity_picture: str | None = None available_fn: Callable[[PlaystationNetworkData], bool] = lambda _: True @@ -49,9 +51,10 @@ class PlaystationNetworkSensor(StrEnum): ONLINE_ID = "online_id" LAST_ONLINE = "last_online" ONLINE_STATUS = "online_status" + NOW_PLAYING = "now_playing" -SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( +SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.TROPHY_LEVEL, translation_key=PlaystationNetworkSensor.TROPHY_LEVEL, @@ -103,6 +106,8 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( else None ), ), +) +SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.ONLINE_ID, translation_key=PlaystationNetworkSensor.ONLINE_ID, @@ -122,10 +127,19 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( PlaystationNetworkSensorEntityDescription( key=PlaystationNetworkSensor.ONLINE_STATUS, translation_key=PlaystationNetworkSensor.ONLINE_STATUS, - value_fn=lambda psn: psn.availability.lower().replace("unavailable", "offline"), + value_fn=( + lambda psn: psn.presence["basicPresence"]["availability"] + .lower() + .replace("unavailable", "offline") + ), device_class=SensorDeviceClass.ENUM, options=["offline", "availabletoplay", "availabletocommunicate", "busy"], ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.NOW_PLAYING, + translation_key=PlaystationNetworkSensor.NOW_PLAYING, + value_fn=lambda psn: get_game_title_info(psn.presence).get("titleName"), + ), ) @@ -138,18 +152,34 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkSensorEntity(coordinator, description) - for description in SENSOR_DESCRIPTIONS + for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER ) + for ( + subentry_id, + friend_data_coordinator, + ) in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkFriendSensorEntity( + friend_data_coordinator, + description, + config_entry.subentries[subentry_id], + ) + for description in SENSOR_DESCRIPTIONS_USER + ], + config_subentry_id=subentry_id, + ) -class PlaystationNetworkSensorEntity( + +class PlaystationNetworkSensorBaseEntity( PlaystationNetworkServiceEntity, SensorEntity, ): - """Representation of a PlayStation Network sensor entity.""" + """Base sensor entity.""" entity_description: PlaystationNetworkSensorEntityDescription - coordinator: PlaystationNetworkUserDataCoordinator + coordinator: PlayStationNetworkBaseCoordinator @property def native_value(self) -> StateType | datetime: @@ -169,14 +199,24 @@ class PlaystationNetworkSensorEntity( (pic.get("url") for pic in profile_pictures if pic.get("size") == "xl"), None, ) - return super().entity_picture @property def available(self) -> bool: """Return True if entity is available.""" - return ( - self.entity_description.available_fn(self.coordinator.data) - and super().available + return super().available and self.entity_description.available_fn( + self.coordinator.data ) + + +class PlaystationNetworkSensorEntity(PlaystationNetworkSensorBaseEntity): + """Representation of a PlayStation Network sensor entity.""" + + coordinator: PlaystationNetworkUserDataCoordinator + + +class PlaystationNetworkFriendSensorEntity(PlaystationNetworkSensorBaseEntity): + """Representation of a PlayStation Network sensor entity.""" + + coordinator: PlaystationNetworkFriendDataCoordinator diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 4fefc508ea2..e5192f42873 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -39,11 +39,40 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_configured_as_subentry": "Already configured as a friend for another account. Delete the existing entry first.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, + "config_subentries": { + "friend": { + "step": { + "user": { + "title": "Friend online status", + "description": "Track the online status of a PlayStation Network friend.", + "data": { + "account_id": "Online ID" + }, + "data_description": { + "account_id": "Select a friend from your friend list to track their online status." + } + } + }, + "initiate_flow": { + "user": "Add friend" + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured_as_entry": "Already configured as a service. This account cannot be added as a friend.", + "already_configured": "Already configured as a friend in this or another account." + } + } + }, "exceptions": { "not_ready": { "message": "Authentication to the PlayStation Network failed." @@ -59,6 +88,12 @@ }, "send_message_failed": { "message": "Failed to send message to group {group_name}. Try again later." + }, + "user_profile_private": { + "message": "Unable to retrieve data for {user}. Privacy settings restrict access to activity." + }, + "user_not_found": { + "message": "Unable to retrieve data for {user}. User does not exist or has been removed." } }, "entity": { @@ -104,6 +139,9 @@ "availabletocommunicate": "Online on PS App", "busy": "Away" } + }, + "now_playing": { + "name": "Now playing" } }, "image": { @@ -112,6 +150,9 @@ }, "avatar": { "name": "Avatar" + }, + "now_playing_image": { + "name": "[%key:component::playstation_network::entity::sensor::now_playing::name%]" } }, "notify": { diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 8480d7ecf5d..ab4edc0e3f4 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models import User from psnawp_api.models.group.group import Group from psnawp_api.models.trophies import ( PlatformType, @@ -13,7 +14,12 @@ from psnawp_api.models.trophies import ( ) import pytest -from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN +from homeassistant.components.playstation_network.const import ( + CONF_ACCOUNT_ID, + CONF_NPSSO, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData from tests.common import MockConfigEntry @@ -32,6 +38,15 @@ def mock_config_entry() -> MockConfigEntry: CONF_NPSSO: NPSSO_TOKEN, }, unique_id=PSN_ID, + subentries_data=[ + ConfigSubentryData( + data={CONF_ACCOUNT_ID: "fren-psn-id"}, + subentry_id="ABCDEF", + subentry_type="friend", + title="PublicUniversalFriend", + unique_id="fren-psn-id", + ) + ], ) @@ -170,6 +185,12 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: ], } client.me.return_value.get_groups.return_value = [group] + fren = MagicMock( + spec=User, account_id="fren-psn-id", online_id="PublicUniversalFriend" + ) + + client.user.return_value.friends_list.return_value = [fren] + yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 894fa2d9084..ca5e9f98628 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -21,7 +21,6 @@ 'title_name': "Assassin's Creed® III Liberation", }), }), - 'availability': 'availableToPlay', 'presence': dict({ 'basicPresence': dict({ 'availability': 'availableToPlay', diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index a00e3c4ff0a..046989cebe6 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -146,6 +146,55 @@ 'state': '2025-06-30T01:42:15+00:00', }) # --- +# name: test_sensors[sensor.testuser_last_online_2-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.testuser_last_online_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_last_online_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testuser Last online', + }), + 'context': , + 'entity_id': 'sensor.testuser_last_online_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- # name: test_sensors[sensor.testuser_next_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -195,6 +244,102 @@ 'state': '19', }) # --- +# name: test_sensors[sensor.testuser_now_playing-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.testuser_now_playing', + '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': 'Now playing', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Now playing', + }), + 'context': , + 'entity_id': 'sensor.testuser_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'STAR WARS Jedi: Survivor™', + }) +# --- +# name: test_sensors[sensor.testuser_now_playing_2-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.testuser_now_playing_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': 'Now playing', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_now_playing_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Now playing', + }), + 'context': , + 'entity_id': 'sensor.testuser_now_playing_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'STAR WARS Jedi: Survivor™', + }) +# --- # name: test_sensors[sensor.testuser_online_id-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -244,6 +389,55 @@ 'state': 'testuser', }) # --- +# name: test_sensors[sensor.testuser_online_id_2-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.testuser_online_id_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': 'Online ID', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_id_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', + 'friendly_name': 'testuser Online ID', + }), + 'context': , + 'entity_id': 'sensor.testuser_online_id_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'testuser', + }) +# --- # name: test_sensors[sensor.testuser_online_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -306,6 +500,68 @@ 'state': 'availabletoplay', }) # --- +# name: test_sensors[sensor.testuser_online_status_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_status_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Online status', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_online_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_status_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'testuser Online status', + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'context': , + 'entity_id': 'sensor.testuser_online_status_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'availabletoplay', + }) +# --- # name: test_sensors[sensor.testuser_platinum_trophies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index dc3ad55c64f..4194f1fb258 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -10,8 +10,17 @@ from homeassistant.components.playstation_network.config_flow import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.components.playstation_network.const import ( + CONF_ACCOUNT_ID, + CONF_NPSSO, + DOMAIN, +) +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntryState, + ConfigSubentry, + ConfigSubentryData, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -67,6 +76,45 @@ async def test_form_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_form_already_configured_as_subentry(hass: HomeAssistant) -> None: + """Test we abort form login when entry is already configured as subentry of another entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="PublicUniversalFriend", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id="fren-psn-id", + subentries_data=[ + ConfigSubentryData( + data={CONF_ACCOUNT_ID: PSN_ID}, + subentry_id="ABCDEF", + subentry_type="friend", + title="test-user", + unique_id=PSN_ID, + ) + ], + ) + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + @pytest.mark.parametrize( ("raise_error", "text_error"), [ @@ -325,3 +373,123 @@ async def test_flow_reconfigure( assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow(hass: HomeAssistant) -> None: + """Test add friend subentry flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type="friend", + title="PublicUniversalFriend", + unique_id="fren-psn-id", + ) + } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we abort add friend subentry flow when already configured.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_add_friend_flow_already_configured_as_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we abort add friend subentry flow when already configured as config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + fren_config_entry = MockConfigEntry( + domain=DOMAIN, + title="PublicUniversalFriend", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id="fren-psn-id", + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + fren_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(fren_config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT_ID: "fren-psn-id"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index c1f2691d623..6db4cb6ab6a 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory from psnawp_api.core import ( PSNAWPAuthenticationError, PSNAWPClientError, + PSNAWPForbiddenError, PSNAWPNotFoundError, PSNAWPServerError, ) @@ -263,3 +264,83 @@ async def test_trophy_title_coordinator_play_new_game( state.attributes["entity_picture"] == "https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG" ) + + +@pytest.mark.parametrize( + "exception", + [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError, PSNAWPForbiddenError], +) +async def test_friends_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test friends coordinator setup fails in _update_data.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = [ + mock_psnawpapi.user.return_value.get_presence.return_value, + exception, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (PSNAWPNotFoundError, ConfigEntryState.SETUP_ERROR), + (PSNAWPAuthenticationError, ConfigEntryState.SETUP_ERROR), + (PSNAWPServerError, ConfigEntryState.SETUP_RETRY), + (PSNAWPClientError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_friends_coordinator_setup_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test friends coordinator setup fails in _async_setup.""" + + mock_psnawpapi.user.side_effect = [ + mock_psnawpapi.user.return_value, + exception, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is state + + +async def test_friends_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test friends coordinator starts reauth on authentication error.""" + mock_psnawpapi.user.side_effect = [ + mock_psnawpapi.user.return_value, + PSNAWPAuthenticationError, + ] + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id From c4d4ef884e6276bcb8e321d2eae73246661ea888 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:13:39 +0200 Subject: [PATCH 0763/1118] Add hassio discovery flow to Uptime Kuma (#148770) --- .../components/uptime_kuma/config_flow.py | 63 +++++- .../components/uptime_kuma/quality_scale.yaml | 8 +- .../components/uptime_kuma/strings.json | 10 + tests/components/uptime_kuma/conftest.py | 11 + .../uptime_kuma/test_config_flow.py | 202 +++++++++++++++++- 5 files changed, 287 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index da71084d1bc..a6429ea7dfe 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -23,6 +23,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DOMAIN @@ -47,7 +48,7 @@ async def validate_connection( hass: HomeAssistant, url: URL | str, verify_ssl: bool, - api_key: str, + api_key: str | None, ) -> dict[str, str]: """Validate Uptime Kuma connectivity.""" errors: dict[str, str] = {} @@ -69,6 +70,8 @@ async def validate_connection( class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Uptime Kuma.""" + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -168,3 +171,61 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for Uptime Kuma add-on. + + This flow is triggered by the discovery component. + """ + self._async_abort_entries_match({CONF_URL: discovery_info.config[CONF_URL]}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured( + updates={CONF_URL: discovery_info.config[CONF_URL]} + ) + + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + api_key = user_input[CONF_API_KEY] if user_input else None + + if not ( + errors := await validate_connection( + self.hass, + self._hassio_discovery.config[CONF_URL], + True, + api_key, + ) + ): + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={ + "addon": self._hassio_discovery.config["addon"] + }, + ) + return self.async_create_entry( + title=self._hassio_discovery.slug, + data={ + CONF_URL: self._hassio_discovery.config[CONF_URL], + CONF_VERIFY_SSL: True, + CONF_API_KEY: api_key, + }, + ) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + description_placeholders={"addon": self._hassio_discovery.config["addon"]}, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 876318c8917..3c9b5a3af50 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -44,12 +44,10 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: exempt - comment: is not locally discoverable + discovery-update-info: done discovery: - status: exempt - comment: is not locally discoverable + status: done + comment: hassio addon supports discovery, other installation methods are not discoverable docs-data-update: done docs-examples: todo docs-known-limitations: done diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 62b1ccbdd9a..e84b68501f3 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -36,6 +36,16 @@ "verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]", "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" } + }, + "hassio_confirm": { + "title": "Uptime Kuma via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the Uptime Kuma service provided by the add-on: {addon}?", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py index 7895f068b31..a092c2e85ba 100644 --- a/tests/components/uptime_kuma/conftest.py +++ b/tests/components/uptime_kuma/conftest.py @@ -10,9 +10,20 @@ from pythonkuma.update import LatestRelease from homeassistant.components.uptime_kuma.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry +ADDON_SERVICE_INFO = HassioServiceInfo( + config={ + "addon": "Uptime Kuma", + CONF_URL: "http://localhost:3001/", + }, + name="Uptime Kuma", + slug="a0d7b954_uptime-kuma", + uuid="1234", +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index ab695107b9b..b8b40a5b759 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -6,11 +6,13 @@ import pytest from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaConnectionException from homeassistant.components.uptime_kuma.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import ADDON_SERVICE_INFO + from tests.common import MockConfigEntry @@ -280,3 +282,201 @@ async def test_flow_reconfigure_errors( } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config flow initiated by Supervisor.""" + mock_pythonkuma.metrics.side_effect = [UptimeKumaAuthenticationException, None] + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Uptime Kuma"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow initiated by Supervisor. + + Config flow will first try to configure without authentication and if it + fails will show the form. + """ + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["description_placeholders"] == {"addon": "Uptime Kuma"} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: None, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test config flow initiated by Supervisor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_hassio_addon_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "apikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "a0d7b954_uptime-kuma" + assert result["data"] == { + CONF_URL: "http://localhost:3001/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + entry_id="123456789", + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_hassio_addon_discovery_update_info( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update from discovery info.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="a0d7b954_uptime-kuma", + data={ + CONF_URL: "http://localhost:80/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://localhost:3001/" From a5b075af68347d629e65523923007f30c2c622e2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Jul 2025 15:20:23 +0200 Subject: [PATCH 0764/1118] Add climate support for MQTT subentries (#149451) Co-authored-by: Norbert Rittel --- homeassistant/components/mqtt/climate.py | 86 +- homeassistant/components/mqtt/config_flow.py | 802 ++++++++++++++++++- homeassistant/components/mqtt/const.py | 37 +- homeassistant/components/mqtt/strings.json | 231 ++++++ tests/components/mqtt/common.py | 123 +++ tests/components/mqtt/test_climate.py | 4 +- tests/components/mqtt/test_config_flow.py | 362 ++++++++- 7 files changed, 1580 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 931a57a71cc..52db0bd25da 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -60,6 +60,17 @@ from .const import ( CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, CONF_CURRENT_TEMP_TOPIC, + CONF_FAN_MODE_COMMAND_TEMPLATE, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_FAN_MODE_LIST, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TOPIC, + CONF_HUMIDITY_COMMAND_TEMPLATE, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_MAX, + CONF_HUMIDITY_MIN, + CONF_HUMIDITY_STATE_TEMPLATE, + CONF_HUMIDITY_STATE_TOPIC, CONF_MODE_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TOPIC, CONF_MODE_LIST, @@ -68,14 +79,39 @@ from .const import ( CONF_POWER_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, + CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_PRESET_MODES_LIST, CONF_RETAIN, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_LIST, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + CONF_SWING_MODE_COMMAND_TEMPLATE, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_LIST, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_STATE_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, CONF_TEMP_INITIAL, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_STATE_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, CONF_TEMP_MAX, CONF_TEMP_MIN, CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, + CONF_TEMP_STEP, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) @@ -95,49 +131,6 @@ PARALLEL_UPDATES = 0 DEFAULT_NAME = "MQTT HVAC" -CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" -CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" -CONF_FAN_MODE_LIST = "fan_modes" -CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" -CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" - -CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" -CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" -CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" -CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" -CONF_HUMIDITY_MAX = "max_humidity" -CONF_HUMIDITY_MIN = "min_humidity" - -CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" -CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" -CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" -CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" -CONF_PRESET_MODES_LIST = "preset_modes" - -CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" -CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" -CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" -CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" -CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" - -CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" -CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" -CONF_SWING_MODE_LIST = "swing_modes" -CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" -CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" - -CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" -CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" -CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" -CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic" -CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" -CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" -CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" -CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" -CONF_TEMP_STEP = "temp_step" - -DEFAULT_INITIAL_TEMPERATURE = 21.0 - MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { climate.ATTR_CURRENT_HUMIDITY, @@ -299,8 +292,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + vol.Optional(CONF_PRECISION): vol.All( + vol.Coerce(float), + vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), ), vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_ACTION_TEMPLATE): cv.template, @@ -577,7 +571,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): init_temp: float = config.get( CONF_TEMP_INITIAL, TemperatureConverter.convert( - DEFAULT_INITIAL_TEMPERATURE, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, UnitOfTemperature.CELSIUS, self.temperature_unit, ), diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 52f00c82c27..03f758dbdce 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -29,6 +29,13 @@ import yaml from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.climate import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, + DEFAULT_MIN_TEMP, + PRESET_NONE, +) from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -80,6 +87,7 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_STATE_TEMPLATE, + CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, @@ -89,8 +97,9 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, EntityCategory, + UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio @@ -115,6 +124,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.unit_conversion import TemperatureConverter from .addon import get_addon_manager from .client import MqttClientSetup @@ -123,6 +133,8 @@ from .const import ( ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_ACTION_TEMPLATE, + CONF_ACTION_TOPIC, CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, @@ -149,6 +161,10 @@ from .const import ( CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_CURRENT_HUMIDITY_TEMPLATE, + CONF_CURRENT_HUMIDITY_TOPIC, + CONF_CURRENT_TEMP_TEMPLATE, + CONF_CURRENT_TEMP_TOPIC, CONF_DIRECTION_COMMAND_TEMPLATE, CONF_DIRECTION_COMMAND_TOPIC, CONF_DIRECTION_STATE_TOPIC, @@ -162,6 +178,11 @@ from .const import ( CONF_EFFECT_VALUE_TEMPLATE, CONF_ENTITY_PICTURE, CONF_EXPIRE_AFTER, + CONF_FAN_MODE_COMMAND_TEMPLATE, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_FAN_MODE_LIST, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TOPIC, CONF_FLASH, CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_SHORT, @@ -172,10 +193,21 @@ from .const import ( CONF_HS_COMMAND_TOPIC, CONF_HS_STATE_TOPIC, CONF_HS_VALUE_TEMPLATE, + CONF_HUMIDITY_COMMAND_TEMPLATE, + CONF_HUMIDITY_COMMAND_TOPIC, + CONF_HUMIDITY_MAX, + CONF_HUMIDITY_MIN, + CONF_HUMIDITY_STATE_TEMPLATE, + CONF_HUMIDITY_STATE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, CONF_MAX_KELVIN, CONF_MIN_KELVIN, + CONF_MODE_COMMAND_TEMPLATE, + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_LIST, + CONF_MODE_STATE_TEMPLATE, + CONF_MODE_STATE_TOPIC, CONF_OFF_DELAY, CONF_ON_COMMAND_TYPE, CONF_OPTIONS, @@ -200,6 +232,9 @@ from .const import ( CONF_PERCENTAGE_VALUE_TEMPLATE, CONF_POSITION_CLOSED, CONF_POSITION_OPEN, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, + CONF_PRECISION, CONF_PRESET_MODE_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, @@ -236,6 +271,32 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUPPORTED_COLOR_MODES, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_LIST, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + CONF_SWING_MODE_COMMAND_TEMPLATE, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_LIST, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TOPIC, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_STATE_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, + CONF_TEMP_INITIAL, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_STATE_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_STATE_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + CONF_TEMP_STEP, CONF_TILT_CLOSED_POSITION, CONF_TILT_COMMAND_TEMPLATE, CONF_TILT_COMMAND_TOPIC, @@ -260,6 +321,7 @@ from .const import ( CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, DEFAULT_BIRTH, + DEFAULT_CLIMATE_INITIAL_TEMPERATURE, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, @@ -392,6 +454,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( SUBENTRY_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.LIGHT, @@ -493,6 +556,59 @@ TIMEOUT_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) ) +# Climate specific selectors +CLIMATE_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["auto", "off", "cool", "heat", "dry", "fan_only"], + multiple=True, + translation_key="climate_modes", + ) +) + + +@callback +def temperature_selector(config: dict[str, Any]) -> Selector: + """Return a temperature selector with configured or system unit.""" + + return NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + +@callback +def temperature_step_selector(config: dict[str, Any]) -> Selector: + """Return a temperature step selector.""" + + return NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + min=0.1, + max=10.0, + step=0.1, + unit_of_measurement=cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + +TEMPERATURE_UNIT_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value="C", label="°C"), + SelectOptionDict(value="F", label="°F"), + ], + mode=SelectSelectorMode.DROPDOWN, + ) +) +PRECISION_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["1.0", "0.5", "0.1"], + mode=SelectSelectorMode.DROPDOWN, + ) +) + # Cover specific selectors POSITION_SELECTOR = NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX)) @@ -567,10 +683,91 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} +# Target temperature feature selector @callback -def validate_cover_platform_config( - config: dict[str, Any], -) -> dict[str, str]: +def configured_target_temperature_feature(config: dict[str, Any]) -> str: + """Calculate current target temperature feature from config.""" + if ( + config == {CONF_PLATFORM: Platform.CLIMATE.value} + or CONF_TEMP_COMMAND_TOPIC in config + ): + # default to single on initial set + return "single" + if CONF_TEMP_HIGH_COMMAND_TOPIC in config: + return "high_low" + return "none" + + +TARGET_TEMPERATURE_FEATURE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=["single", "high_low", "none"], + mode=SelectSelectorMode.DROPDOWN, + translation_key="target_temperature_feature", + ) +) +HUMIDITY_SELECTOR = vol.All( + NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=100, step=1) + ), + vol.Coerce(int), +) + + +@callback +def temperature_default_from_celsius_to_system_default( + value: float, +) -> Callable[[dict[str, Any]], int]: + """Return temperature in Celsius in system default unit.""" + + def _default(config: dict[str, Any]) -> int: + return round( + TemperatureConverter.convert( + value, + UnitOfTemperature.CELSIUS, + cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]), + ) + ) + + return _default + + +@callback +def default_precision(config: dict[str, Any]) -> str: + """Return the thermostat precision for system default unit.""" + + return str( + config.get( + CONF_PRECISION, + 0.1 + if cv.temperature_unit(config[CONF_TEMPERATURE_UNIT]) + is UnitOfTemperature.CELSIUS + else 1.0, + ) + ) + + +@callback +def validate_climate_platform_config(config: dict[str, Any]) -> dict[str, str]: + """Validate the climate platform options.""" + errors: dict[str, str] = {} + if ( + CONF_PRESET_MODES_LIST in config + and PRESET_NONE in config[CONF_PRESET_MODES_LIST] + ): + errors["climate_preset_mode_settings"] = "preset_mode_none_not_allowed" + if ( + CONF_HUMIDITY_MIN in config + and config[CONF_HUMIDITY_MIN] >= config[CONF_HUMIDITY_MAX] + ): + errors["target_humidity_settings"] = "max_below_min_humidity" + if CONF_TEMP_MIN in config and config[CONF_TEMP_MIN] >= config[CONF_TEMP_MAX]: + errors["target_temperature_settings"] = "max_below_min_temperature" + + return errors + + +@callback +def validate_cover_platform_config(config: dict[str, Any]) -> dict[str, str]: """Validate the cover platform options.""" errors: dict[str, str] = {} @@ -680,6 +877,14 @@ def validate_sensor_platform_config( return errors +@callback +def no_empty_list(value: list[Any]) -> list[Any]: + """Validate a selector returns at least one item.""" + if not value: + raise vol.Invalid("empty_list_not_allowed") + return value + + @callback def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: """Run validator, then return the unmodified input.""" @@ -695,13 +900,13 @@ def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: class PlatformField: """Stores a platform config field schema, required flag and validator.""" - selector: Selector[Any] | Callable[..., Selector[Any]] + selector: Selector[Any] | Callable[[dict[str, Any]], Selector[Any]] required: bool - validator: Callable[..., Any] | None = None + validator: Callable[[Any], Any] | None = None error: str | None = None - default: ( - str | int | bool | None | Callable[[dict[str, Any]], Any] | vol.Undefined - ) = vol.UNDEFINED + default: Any | None | Callable[[dict[str, Any]], Any] | vol.Undefined = ( + vol.UNDEFINED + ) is_schema_default: bool = False exclude_from_reconfig: bool = False exclude_from_config: bool = False @@ -790,6 +995,78 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { required=False, ), }, + Platform.CLIMATE.value: { + CONF_TEMPERATURE_UNIT: PlatformField( + selector=TEMPERATURE_UNIT_SELECTOR, + validator=validate(cv.temperature_unit), + required=True, + exclude_from_reconfig=True, + default=lambda _: "C" + if async_get_hass().config.units.temperature_unit + is UnitOfTemperature.CELSIUS + else "F", + ), + "climate_feature_action": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_ACTION_TOPIC)), + ), + "climate_feature_target_temperature": PlatformField( + selector=TARGET_TEMPERATURE_FEATURE_SELECTOR, + required=False, + exclude_from_config=True, + default=configured_target_temperature_feature, + ), + "climate_feature_current_temperature": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_CURRENT_TEMP_TOPIC)), + ), + "climate_feature_target_humidity": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_HUMIDITY_COMMAND_TOPIC)), + ), + "climate_feature_current_humidity": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_HUMIDITY_STATE_TOPIC)), + ), + "climate_feature_preset_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_PRESET_MODES_LIST)), + ), + "climate_feature_fan_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_FAN_MODE_LIST)), + ), + "climate_feature_swing_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_SWING_MODE_LIST)), + ), + "climate_feature_swing_horizontal_modes": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_SWING_HORIZONTAL_MODE_LIST)), + ), + "climate_feature_power": PlatformField( + selector=BOOLEAN_SELECTOR, + required=False, + exclude_from_config=True, + default=lambda config: bool(config.get(CONF_POWER_COMMAND_TOPIC)), + ), + }, Platform.COVER.value: { CONF_DEVICE_CLASS: PlatformField( selector=COVER_DEVICE_CLASS_SELECTOR, @@ -929,6 +1206,496 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, + Platform.CLIMATE.value: { + # operation mode settings + CONF_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_MODE_LIST: PlatformField( + selector=CLIMATE_MODE_SELECTOR, + required=True, + default=[], + validator=validate(no_empty_list), + error="empty_list_not_allowed", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool) + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool) + ), + # current action settings + CONF_ACTION_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_action_settings", + conditions=({"climate_feature_action": True},), + ), + CONF_ACTION_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_action_settings", + conditions=({"climate_feature_action": True},), + ), + # target temperature settings + CONF_TEMP_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "single"},), + ), + CONF_TEMP_LOW_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_LOW_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_HIGH_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_temperature_settings", + conditions=({"climate_feature_target_temperature": "high_low"},), + ), + CONF_TEMP_MIN: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=True, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_MIN_TEMP + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_MAX: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=True, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_MAX_TEMP + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_PRECISION: PlatformField( + selector=PRECISION_SELECTOR, + required=False, + default=default_precision, + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_STEP: PlatformField( + selector=temperature_step_selector, + custom_filtering=True, + required=False, + default=1.0, + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + CONF_TEMP_INITIAL: PlatformField( + selector=temperature_selector, + custom_filtering=True, + required=False, + default=temperature_default_from_celsius_to_system_default( + DEFAULT_CLIMATE_INITIAL_TEMPERATURE + ), + section="target_temperature_settings", + conditions=( + {"climate_feature_target_temperature": "high_low"}, + {"climate_feature_target_temperature": "single"}, + ), + ), + # current temperature settings + CONF_CURRENT_TEMP_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="current_temperature_settings", + conditions=({"climate_feature_current_temperature": True},), + ), + CONF_CURRENT_TEMP_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="current_temperature_settings", + conditions=({"climate_feature_current_temperature": True},), + ), + # target humidity settings + CONF_HUMIDITY_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_MIN: PlatformField( + selector=HUMIDITY_SELECTOR, + required=True, + default=DEFAULT_MIN_HUMIDITY, + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + CONF_HUMIDITY_MAX: PlatformField( + selector=HUMIDITY_SELECTOR, + required=True, + default=DEFAULT_MAX_HUMIDITY, + section="target_humidity_settings", + conditions=({"climate_feature_target_humidity": True},), + ), + # current humidity settings + CONF_CURRENT_HUMIDITY_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="current_humidity_settings", + conditions=({"climate_feature_current_humidity": True},), + ), + CONF_CURRENT_HUMIDITY_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="current_humidity_settings", + conditions=({"climate_feature_current_humidity": True},), + ), + # power on/off support + CONF_POWER_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_POWER_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_PAYLOAD_OFF: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_OFF, + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + CONF_PAYLOAD_ON: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ON, + section="climate_power_settings", + conditions=({"climate_feature_power": True},), + ), + # preset mode settings + CONF_PRESET_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODE_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + CONF_PRESET_MODES_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_preset_mode_settings", + conditions=({"climate_feature_preset_modes": True},), + ), + # fan mode settings + CONF_FAN_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + CONF_FAN_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_fan_mode_settings", + conditions=({"climate_feature_fan_modes": True},), + ), + # swing mode settings + CONF_SWING_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + CONF_SWING_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_swing_mode_settings", + conditions=({"climate_feature_swing_modes": True},), + ), + # swing horizontal mode settings + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + CONF_SWING_HORIZONTAL_MODE_LIST: PlatformField( + selector=PRESET_MODES_SELECTOR, + required=True, + validator=validate(no_empty_list), + error="empty_list_not_allowed", + section="climate_swing_horizontal_mode_settings", + conditions=({"climate_feature_swing_horizontal_modes": True},), + ), + }, Platform.COVER.value: { CONF_COMMAND_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -1904,6 +2671,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ ] = { Platform.BINARY_SENSOR.value: None, Platform.BUTTON.value: None, + Platform.CLIMATE.value: validate_climate_platform_config, Platform.COVER.value: validate_cover_platform_config, Platform.FAN.value: validate_fan_platform_config, Platform.LIGHT.value: validate_light_platform_config, @@ -2097,15 +2865,15 @@ def data_schema_from_fields( no_reconfig_options: set[Any] = set() for schema_section in sections: data_schema_element = { - vol.Required(field_name, default=field_details.default) + vol.Required(field_name, default=get_default(field_details)) if field_details.required else vol.Optional( field_name, default=get_default(field_details) if field_details.default is not None else vol.UNDEFINED, - ): field_details.selector(component_data_with_user_input) # type: ignore[operator] - if field_details.custom_filtering + ): field_details.selector(component_data_with_user_input or {}) + if callable(field_details.selector) and field_details.custom_filtering else field_details.selector for field_name, field_details in data_schema_fields.items() if not field_details.is_schema_default @@ -2127,12 +2895,20 @@ def data_schema_from_fields( if not data_schema_element: # Do not show empty sections continue + # Collapse if values are changed or required fields need to be set collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED - or component_data_with_user_input[str(option)] != default + or ( + str(option) in component_data_with_user_input + and component_data_with_user_input[str(option)] != default + ) for option in data_element_options if option in component_data_with_user_input + or ( + str(option) in data_schema_fields + and data_schema_fields[str(option)].required + ) ) if component_data_with_user_input is not None else True diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c60aa674b1b..1dfdb8dac53 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -26,7 +26,6 @@ CONF_PAYLOAD_AVAILABLE = "payload_available" CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_AVAILABILITY = "availability" - CONF_AVAILABILITY_MODE = "availability_mode" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_AVAILABILITY_TOPIC = "availability_topic" @@ -53,7 +52,6 @@ CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" CONF_SUPPORTED_FEATURES = "supported_features" - CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" CONF_BLUE_TEMPLATE = "blue_template" @@ -91,6 +89,11 @@ CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" CONF_EXPIRE_AFTER = "expire_after" +CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" +CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" +CONF_FAN_MODE_LIST = "fan_modes" +CONF_FAN_MODE_STATE_TEMPLATE = "fan_mode_state_template" +CONF_FAN_MODE_STATE_TOPIC = "fan_mode_state_topic" CONF_FLASH = "flash" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" @@ -101,6 +104,12 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" +CONF_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" +CONF_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" +CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" +CONF_HUMIDITY_MAX = "max_humidity" +CONF_HUMIDITY_MIN = "min_humidity" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" CONF_MAX_MIREDS = "max_mireds" @@ -166,13 +175,32 @@ CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" +CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" +CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" +CONF_SWING_MODE_LIST = "swing_modes" +CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" +CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" -CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" -CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" +CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" +CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" +CONF_TEMP_HIGH_STATE_TOPIC = "temperature_high_state_topic" CONF_TEMP_INITIAL = "initial" +CONF_TEMP_LOW_COMMAND_TEMPLATE = "temperature_low_command_template" +CONF_TEMP_LOW_COMMAND_TOPIC = "temperature_low_command_topic" +CONF_TEMP_LOW_STATE_TEMPLATE = "temperature_low_state_template" +CONF_TEMP_LOW_STATE_TOPIC = "temperature_low_state_topic" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" +CONF_TEMP_STATE_TOPIC = "temperature_state_topic" +CONF_TEMP_STEP = "temp_step" CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" CONF_TILT_COMMAND_TOPIC = "tilt_command_topic" CONF_TILT_STATUS_TOPIC = "tilt_status_topic" @@ -213,6 +241,7 @@ CONF_SUPPORT_URL = "support_url" DEFAULT_BRIGHTNESS = False DEFAULT_BRIGHTNESS_SCALE = 255 +DEFAULT_CLIMATE_INITIAL_TEMPERATURE = 21.0 DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 92900d8292d..22fb85780b0 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -239,6 +239,16 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { + "climate_feature_action": "Current action support", + "climate_feature_current_humidity": "Current humidity support", + "climate_feature_current_temperature": "Current temperature support", + "climate_feature_fan_modes": "Fan mode support", + "climate_feature_power": "Power on/off support", + "climate_feature_preset_modes": "[%key:component::mqtt::config_subentries::device::step::entity_platform_config::data::fan_feature_preset_modes%]", + "climate_feature_swing_horizontal_modes": "Horizontal swing mode support", + "climate_feature_swing_modes": "Swing mode support", + "climate_feature_target_temperature": "Target temperature support", + "climate_feature_target_humidity": "Target humidity support", "device_class": "Device class", "entity_category": "Entity category", "fan_feature_speed": "Speed support", @@ -249,9 +259,20 @@ "schema": "Schema", "state_class": "State class", "suggested_display_precision": "Suggested display precision", + "temperature_unit": "Temperature unit", "unit_of_measurement": "Unit of measurement" }, "data_description": { + "climate_feature_action": "The climate supports reporting the current action.", + "climate_feature_current_humidity": "The climate supports reporting the current humidity.", + "climate_feature_current_temperature": "The climate supports reporting the current temperature.", + "climate_feature_fan_modes": "The climate supports fan modes.", + "climate_feature_power": "The climate supports the power \"on\" and \"off\" commands.", + "climate_feature_preset_modes": "The climate supports preset modes.", + "climate_feature_swing_horizontal_modes": "The climate supports horizontal swing modes.", + "climate_feature_swing_modes": "The climate supports swing modes.", + "climate_feature_target_temperature": "The climate supports setting the target temperature.", + "climate_feature_target_humidity": "The climate supports setting the target humidity.", "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", "entity_category": "Allows marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", "fan_feature_speed": "The fan supports multiple speeds.", @@ -262,6 +283,7 @@ "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.", "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." }, "sections": { @@ -290,6 +312,11 @@ "force_update": "Force update", "green_template": "Green template", "last_reset_value_template": "Last reset value template", + "modes": "Supported operation modes", + "mode_command_topic": "Operation mode command topic", + "mode_command_template": "Operation mode command template", + "mode_state_topic": "Operation mode state topic", + "mode_state_template": "Operation mode value template", "on_command_type": "ON command type", "optimistic": "Optimistic", "payload_off": "Payload \"off\"", @@ -317,6 +344,11 @@ "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "modes": "A list of supported operation modes. [Learn more.]({url}#modes)", + "mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)", + "mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)", + "mode_state_topic": "The MQTT topic subscribed to receive operation mode state messages. [Learn more.]({url}#mode_state_topic)", + "mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)", "on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", "payload_off": "The payload that represents the \"off\" state.", @@ -356,6 +388,100 @@ "transition": "Enable the transition feature for this light" } }, + "climate_action_settings": { + "name": "Current action settings", + "data": { + "action_template": "Action template", + "action_topic": "Action topic" + }, + "data_description": { + "action_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the action topic with.", + "action_topic": "The MQTT topic to subscribe for changes of the current action. If this is set, the climate graph uses the value received as data source. A \"None\" payload resets the current action state. An empty payload is ignored. Valid action values are: \"off\", \"heating\", \"cooling\", \"drying\", \"idle\" and \"fan\". [Learn more.]({url}#action_topic)" + } + }, + "climate_fan_mode_settings": { + "name": "Fan mode settings", + "data": { + "fan_modes": "Fan modes", + "fan_mode_command_topic": "Fan mode command topic", + "fan_mode_command_template": "Fan mode command template", + "fan_mode_state_topic": "Fan mode state topic", + "fan_mode_state_template": "Fan mode state template" + }, + "data_description": { + "fan_modes": "List of fan modes this climate is capable of running at. Common fan modes that offer translations are `off`, `on`, `auto`, `low`, `medium`, `high`, `middle`, `focus` and `diffuse`.", + "fan_mode_command_topic": "The MQTT topic to publish commands to change the climate fan mode. [Learn more.]({url}#fan_mode_command_topic)", + "fan_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the fan mode command topic.", + "fan_mode_state_topic": "The MQTT topic subscribed to receive the climate fan mode. [Learn more.]({url}#fan_mode_state_topic)", + "fan_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate fan mode value." + } + }, + "climate_power_settings": { + "name": "Power settings", + "data": { + "payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_off%]", + "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_on%]", + "power_command_template": "Power command template", + "power_command_topic": "Power command topic" + }, + "data_description": { + "payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]", + "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]", + "power_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the power command topic. The `value` parameter is the payload set for payload \"on\" or payload \"off\".", + "power_command_topic": "The MQTT topic to publish commands to change the climate power state. Sends the payload configured with payload \"on\" or payload \"off\". [Learn more.]({url}#power_command_topic)" + } + }, + "climate_preset_mode_settings": { + "name": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::name%]", + "data": { + "preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_template%]", + "preset_mode_command_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_command_topic%]", + "preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_value_template%]", + "preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_mode_state_topic%]", + "preset_modes": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data::preset_modes%]" + }, + "data_description": { + "preset_mode_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_command_template%]", + "preset_mode_command_topic": "The MQTT topic to publish commands to change the climate preset mode. [Learn more.]({url}#preset_mode_command_topic)", + "preset_mode_value_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_value_template%]", + "preset_mode_state_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::fan_preset_mode_settings::data_description::preset_mode_state_topic%]", + "preset_modes": "List of preset modes this climate is capable of running at. Common preset modes that offer translations are `none`, `away`, `eco`, `boost`, `comfort`, `home`, `sleep` and `activity`." + } + }, + "climate_swing_horizontal_mode_settings": { + "name": "Horizontal swing mode settings", + "data": { + "swing_horizontal_modes": "Horizontal swing modes", + "swing_horizontal_mode_command_topic": "Horizontal swing mode command topic", + "swing_horizontal_mode_command_template": "Horizontal swing mode command template", + "swing_horizontal_mode_state_topic": "Horizontal swing mode state topic", + "swing_horizontal_mode_state_template": "Horizontal swing mode state template" + }, + "data_description": { + "swing_horizontal_modes": "List of horizontal swing modes this climate is capable of running at. Common horizontal swing modes that offer translations are `off` and `on`.", + "swing_horizontal_mode_command_topic": "The MQTT topic to publish commands to change the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_command_topic)", + "swing_horizontal_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the horizontal swing mode command topic.", + "swing_horizontal_mode_state_topic": "The MQTT topic subscribed to receive the climate horizontal swing mode. [Learn more.]({url}#swing_horizontal_mode_state_topic)", + "swing_horizontal_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate horizontal swing mode value." + } + }, + "climate_swing_mode_settings": { + "name": "Swing mode settings", + "data": { + "swing_modes": "Swing modes", + "swing_mode_command_topic": "Swing mode command topic", + "swing_mode_command_template": "Swing mode command template", + "swing_mode_state_topic": "Swing mode state topic", + "swing_mode_state_template": "Swing mode state template" + }, + "data_description": { + "swing_modes": "List of swing modes this climate is capable of running at. Common swing modes that offer translations are `off`, `on`, `vertical`, `horizontal` and `both`.", + "swing_mode_command_topic": "The MQTT topic to publish commands to change the climate swing mode. [Learn more.]({url}#swing_mode_command_topic)", + "swing_mode_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the swing mode command topic.", + "swing_mode_state_topic": "The MQTT topic subscribed to receive the climate swing mode. [Learn more.]({url}#swing_mode_state_topic)", + "swing_mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the climate swing mode value." + } + }, "cover_payload_settings": { "name": "Payload settings", "data": { @@ -425,6 +551,28 @@ "tilt_optimistic": "Flag that defines if tilt works in optimistic mode. If tilt status topic is not defined, tilt works in optimistic mode by default. [Learn more.]({url}#tilt_optimistic)" } }, + "current_humidity_settings": { + "name": "Current humidity settings", + "data": { + "current_humidity_template": "Current humidity template", + "current_humidity_topic": "Current humidity topic" + }, + "data_description": { + "current_humidity_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current humidity value. [Learn more.]({url}#current_humidity_template)", + "current_humidity_topic": "The MQTT topic subscribed to receive current humidity update values. [Learn more.]({url}#current_humidity_topic)" + } + }, + "current_temperature_settings": { + "name": "Current temperature settings", + "data": { + "current_temperature_template": "Current temperature template", + "current_temperature_topic": "Current temperature topic" + }, + "data_description": { + "current_temperature_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the current temperature value. [Learn more.]({url}#current_temperature_template)", + "current_temperature_topic": "The MQTT topic subscribed to receive current temperature update values. [Learn more.]({url}#current_temperature_topic)" + } + }, "light_brightness_settings": { "name": "Brightness settings", "data": { @@ -648,6 +796,66 @@ "xy_state_topic": "The MQTT topic subscribed to receive XY state updates. The expected payload is the X and Y color values separated by commas, for example, `0.675,0.322`. [Learn more.]({url}#xy_state_topic)", "xy_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the XY value." } + }, + "target_humidity_settings": { + "name": "Target humidity settings", + "data": { + "max_humidity": "Maximum humidity", + "min_humidity": "Minimum humidity", + "target_humidity_command_template": "Humidity command template", + "target_humidity_command_topic": "Humidity command topic", + "target_humidity_state_template": "Humidity state template", + "target_humidity_state_topic": "Humidity state topic" + }, + "data_description": { + "max_humidity": "The maximum target humidity that can be set.", + "min_humidity": "The minimum target humidity that can be set.", + "target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the humidity command topic.", + "target_humidity_command_topic": "The MQTT topic to publish commands to change the climate target humidity. [Learn more.]({url}#humidity_command_topic)", + "target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the humidity state topic with.", + "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" + } + }, + "target_temperature_settings": { + "name": "Target temperature settings", + "data": { + "initial": "Initial temperature", + "max_temp": "Maximum temperature", + "min_temp": "Minimum temperature", + "precision": "Precision", + "temp_step": "Temperature step", + "temperature_command_template": "Temperature command template", + "temperature_command_topic": "Temperature command topic", + "temperature_high_command_template": "Upper temperature command template", + "temperature_high_command_topic": "Upper temperature command topic", + "temperature_low_command_template": "Lower temperature command template", + "temperature_low_command_topic": "Lower temperature command topic", + "temperature_state_template": "Temperature state template", + "temperature_state_topic": "Temperature state topic", + "temperature_high_state_template": "Upper temperature state template", + "temperature_high_state_topic": "Upper temperature state topic", + "temperature_low_state_template": "Lower temperature state template", + "temperature_low_state_topic": "Lower temperature state topic" + }, + "data_description": { + "initial": "The climate initalizes with this target temperature.", + "max_temp": "The maximum target temperature that can be set.", + "min_temp": "The minimum target temperature that can be set.", + "precision": "The precision in degrees the thermostat is working at.", + "temp_step": "The target temperature step in degrees Celsius or Fahrenheit.", + "temperature_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the temperature command topic.", + "temperature_command_topic": "The MQTT topic to publish commands to change the climate target temperature. [Learn more.]({url}#temperature_command_topic)", + "temperature_high_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the upper temperature command topic.", + "temperature_high_command_topic": "The MQTT topic to publish commands to change the climate upper target temperature. [Learn more.]({url}#temperature_high_command_topic)", + "temperature_low_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the lower temperature command topic.", + "temperature_low_command_topic": "The MQTT topic to publish commands to change the climate lower target temperature. [Learn more.]({url}#temperature_low_command_topic)", + "temperature_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the temperature state topic with.", + "temperature_state_topic": "The MQTT topic to subscribe for changes of the target temperature. [Learn more.]({url}#temperature_state_topic)", + "temperature_high_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the upper temperature state topic with.", + "temperature_high_state_topic": "The MQTT topic to subscribe for changes of the upper target temperature. [Learn more.]({url}#temperature_high_state_topic)", + "temperature_low_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the lower temperature state topic with.", + "temperature_low_state_topic": "The MQTT topic to subscribe for changes of the lower target temperature. [Learn more.]({url}#temperature_low_state_topic)" + } } } }, @@ -695,6 +903,7 @@ "cover_tilt_command_template_must_be_used_with_tilt_command_topic": "The tilt command template must be used with the tilt command topic", "cover_tilt_status_template_must_be_used_with_tilt_status_topic": "The tilt value template must be used with the tilt status topic", "cover_value_template_must_be_used_with_state_topic": "The value template must be used with the state topic option", + "empty_list_not_allowed": "Empty list is not allowed. Add at least one item", "fan_speed_range_max_must_be_greater_than_speed_range_min": "Speed range max must be greater than speed range min", "fan_preset_mode_reset_in_preset_modes_list": "Payload \"reset preset mode\" is not a valid as a preset mode", "invalid_input": "Invalid value", @@ -705,10 +914,13 @@ "invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min_humidity": "Max humidity value should be greater than min humidity value", "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", + "max_below_min_temperature": "Max temperature value should be greater than min temperature value", "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", "options_with_enum_device_class": "Configure options for the enumeration sensor", + "preset_mode_none_not_allowed": "Preset \"none\" is not a valid preset mode", "uom_required_for_device_class": "The selected device class requires a unit" } } @@ -826,6 +1038,17 @@ } }, "selector": { + "climate_modes": { + "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]", + "heat": "[%key:component::climate::entity_component::_::state::heat%]", + "cool": "[%key:component::climate::entity_component::_::state::cool%]", + "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", + "dry": "[%key:component::climate::entity_component::_::state::dry%]", + "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" + } + }, "device_class_binary_sensor": { "options": { "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", @@ -969,6 +1192,7 @@ "options": { "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", + "climate": "[%key:component::climate::title%]", "cover": "[%key:component::cover::title%]", "fan": "[%key:component::fan::title%]", "light": "[%key:component::light::title%]", @@ -1004,6 +1228,13 @@ "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]" } + }, + "target_temperature_feature": { + "options": { + "single": "Single target temperature", + "high_low": "Upper/lower target temperature", + "none": "No target temperature" + } } }, "services": { diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 3e87925c1cd..15e203eab06 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -94,6 +94,117 @@ MOCK_SUBENTRY_BUTTON_COMPONENT = { "entity_picture": "https://example.com/365d05e6607c4dfb8ae915cff71a954b", }, } +MOCK_SUBENTRY_CLIMATE_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851386": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851386", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # single target temperature + "temperature_command_topic": "temperature-command-topic", + "temperature_command_template": "{{ value }}", + "temperature_state_topic": "temperature-state-topic", + "temperature_state_template": "{{ value_json.temperature }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + # power settings + "power_command_topic": "power-command-topic", + "power_command_template": "{{ value }}", + "payload_on": "ON", + "payload_off": "OFF", + # current action settings + "action_topic": "action-topic", + "action_template": "{{ value_json.current_action }}", + # target humidity + "target_humidity_command_topic": "target-humidity-command-topic", + "target_humidity_command_template": "{{ value }}", + "target_humidity_state_topic": "target-humidity-state-topic", + "target_humidity_state_template": "{{ value_json.target_humidity }}", + "min_humidity": 20, + "max_humidity": 80, + # current temperature + "current_temperature_topic": "current-temperature-topic", + "current_temperature_template": "{{ value_json.temperature }}", + # current humidity + "current_humidity_topic": "current-humidity-topic", + "current_humidity_template": "{{ value_json.humidity }}", + # preset mode + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_command_template": "{{ value }}", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "preset_modes": ["auto", "eco"], + # fan mode + "fan_mode_command_topic": "fan-mode-command-topic", + "fan_mode_command_template": "{{ value }}", + "fan_mode_state_topic": "fan-mode-state-topic", + "fan_mode_state_template": "{{ value_json.fan_mode }}", + "fan_modes": ["off", "low", "medium", "high"], + # swing mode + "swing_mode_command_topic": "swing-mode-command-topic", + "swing_mode_command_template": "{{ value }}", + "swing_mode_state_topic": "swing-mode-state-topic", + "swing_mode_state_template": "{{ value_json.swing_mode }}", + "swing_modes": ["off", "on"], + # swing horizontal mode + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-command-topic", + "swing_horizontal_mode_command_template": "{{ value }}", + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", + "swing_horizontal_mode_state_template": "{{ value_json.swing_horizontal_mode }}", + "swing_horizontal_modes": ["off", "on"], + }, +} +MOCK_SUBENTRY_CLIMATE_HIGH_LOW_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851387": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851387", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # high/low target temperature + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, +} +MOCK_SUBENTRY_CLIMATE_NO_TARGET_TEMP_COMPONENT = { + "b085c09efba7ec76acd94e2e0f851388": { + "platform": "climate", + "name": "Cooler", + "entity_category": None, + "entity_picture": "https://example.com/b085c09efba7ec76acd94e2e0f851388", + "temperature_unit": "C", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + }, +} MOCK_SUBENTRY_COVER_COMPONENT = { "b37acf667fa04c688ad7dfb27de2178b": { "platform": "cover", @@ -312,6 +423,18 @@ MOCK_BUTTON_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, "components": MOCK_SUBENTRY_BUTTON_COMPONENT, } +MOCK_CLIMATE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_CLIMATE_COMPONENT, +} +MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_CLIMATE_HIGH_LOW_COMPONENT, +} +MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, + "components": MOCK_SUBENTRY_CLIMATE_NO_TARGET_TEMP_COMPONENT, +} MOCK_COVER_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_COVER_COMPONENT, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 568fb7ea39d..333febe8844 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -29,10 +29,12 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.mqtt.climate import ( - DEFAULT_INITIAL_TEMPERATURE, MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) +from homeassistant.components.mqtt.const import ( + DEFAULT_CLIMATE_INITIAL_TEMPERATURE as DEFAULT_INITIAL_TEMPERATURE, +) from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index ce0a0c44a79..ff1f954bace 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -35,6 +35,9 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_BUTTON_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, + MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, @@ -2700,6 +2703,224 @@ async def test_migrate_of_incompatible_config_entry( ), "Milk notifier Restart", ), + ( + MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": True, + "climate_feature_current_humidity": True, + "climate_feature_current_temperature": True, + "climate_feature_power": True, + "climate_feature_preset_modes": True, + "climate_feature_fan_modes": True, + "climate_feature_swing_horizontal_modes": True, + "climate_feature_swing_modes": True, + "climate_feature_target_temperature": "single", + "climate_feature_target_humidity": True, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # single target temperature + "target_temperature_settings": { + "temperature_command_topic": "temperature-command-topic", + "temperature_command_template": "{{ value }}", + "temperature_state_topic": "temperature-state-topic", + "temperature_state_template": "{{ value_json.temperature }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + # power settings + "climate_power_settings": { + "power_command_topic": "power-command-topic", + "power_command_template": "{{ value }}", + "payload_on": "ON", + "payload_off": "OFF", + }, + # current action settings + "climate_action_settings": { + "action_topic": "action-topic", + "action_template": "{{ value_json.current_action }}", + }, + # target humidity + "target_humidity_settings": { + "target_humidity_command_topic": "target-humidity-command-topic", + "target_humidity_command_template": "{{ value }}", + "target_humidity_state_topic": "target-humidity-state-topic", + "target_humidity_state_template": "{{ value_json.target_humidity }}", + "min_humidity": 20, + "max_humidity": 80, + }, + # current temperature + "current_temperature_settings": { + "current_temperature_topic": "current-temperature-topic", + "current_temperature_template": "{{ value_json.temperature }}", + }, + # current humidity + "current_humidity_settings": { + "current_humidity_topic": "current-humidity-topic", + "current_humidity_template": "{{ value_json.humidity }}", + }, + # preset mode + "climate_preset_mode_settings": { + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_command_template": "{{ value }}", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_value_template": "{{ value_json.preset_mode }}", + "preset_modes": ["auto", "eco"], + }, + # fan mode + "climate_fan_mode_settings": { + "fan_mode_command_topic": "fan-mode-command-topic", + "fan_mode_command_template": "{{ value }}", + "fan_mode_state_topic": "fan-mode-state-topic", + "fan_mode_state_template": "{{ value_json.fan_mode }}", + "fan_modes": ["off", "low", "medium", "high"], + }, + # swing mode + "climate_swing_mode_settings": { + "swing_mode_command_topic": "swing-mode-command-topic", + "swing_mode_command_template": "{{ value }}", + "swing_mode_state_topic": "swing-mode-state-topic", + "swing_mode_state_template": "{{ value_json.swing_mode }}", + "swing_modes": ["off", "on"], + }, + # swing horizontal mode + "climate_swing_horizontal_mode_settings": { + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-command-topic", + "swing_horizontal_mode_command_template": "{{ value }}", + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", + "swing_horizontal_mode_state_template": "{{ value_json.swing_horizontal_mode }}", + "swing_horizontal_modes": ["off", "on"], + }, + }, + ( + ( + { + "modes": ["off", "heat", "cool", "auto"], + "target_temperature_settings": { + "temperature_command_topic": "test-topic#invalid" + }, + }, + {"target_temperature_settings": "invalid_publish_topic"}, + ), + ( + { + "modes": [], + "target_temperature_settings": { + "temperature_command_topic": "test-topic" + }, + }, + {"modes": "empty_list_not_allowed"}, + ), + ( + { + "modes": ["off", "heat", "cool", "auto"], + "target_temperature_settings": { + "temperature_command_topic": "test-topic", + "min_temp": 19.0, + "max_temp": 18.0, + }, + "target_humidity_settings": { + "target_humidity_command_topic": "test-topic", + "min_humidity": 50, + "max_humidity": 40, + }, + "climate_preset_mode_settings": { + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["none"], + }, + }, + { + "target_temperature_settings": "max_below_min_temperature", + "target_humidity_settings": "max_below_min_humidity", + "climate_preset_mode_settings": "preset_mode_none_not_allowed", + }, + ), + ), + "Milk notifier Cooler", + ), + ( + MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + (), + "Milk notifier Cooler", + ), + ( + MOCK_CLIMATE_NO_TARGET_TEMP_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Cooler"}, + { + "temperature_unit": "C", + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "none", + "climate_feature_target_humidity": False, + }, + (), + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool", "auto"], + }, + (), + "Milk notifier Cooler", + ), ( MOCK_COVER_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, @@ -3130,6 +3351,9 @@ async def test_migrate_of_incompatible_config_entry( ids=[ "binary_sensor", "button", + "climate_single", + "climate_high_low", + "climate_no_target_temp", "cover", "fan", "notify_with_entity_name", @@ -3631,8 +3855,144 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( }, {"optimistic", "state_value_template", "entity_picture"}, ), + ( + ( + ConfigSubentryData( + data=MOCK_CLIMATE_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + {}, + { + "current_humidity_topic", + "action_topic", + "swing_modes", + "max_humidity", + "fan_modes", + "action_template", + "current_temperature_template", + "temperature_state_template", + "entity_picture", + "target_humidity_state_template", + "fan_mode_state_topic", + "swing_horizontal_mode_command_template", + "power_command_template", + "swing_horizontal_modes", + "current_temperature_topic", + "temperature_command_topic", + "swing_mode_command_topic", + "fan_mode_command_template", + "swing_horizontal_mode_state_template", + "preset_mode_command_template", + "swing_mode_command_template", + "temperature_state_topic", + "preset_mode_value_template", + "fan_mode_state_template", + "swing_horizontal_mode_command_topic", + "min_humidity", + "temperature_command_template", + "preset_modes", + "swing_horizontal_mode_state_topic", + "target_humidity_state_topic", + "target_humidity_command_topic", + "preset_mode_command_topic", + "payload_on", + "payload_off", + "power_command_topic", + "current_humidity_template", + "preset_mode_state_topic", + "fan_mode_command_topic", + "swing_mode_state_template", + "target_humidity_command_template", + "swing_mode_state_topic", + }, + ), + ( + ( + ConfigSubentryData( + data=MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "climate_feature_action": False, + "climate_feature_current_humidity": False, + "climate_feature_current_temperature": False, + "climate_feature_power": False, + "climate_feature_preset_modes": False, + "climate_feature_fan_modes": False, + "climate_feature_swing_horizontal_modes": False, + "climate_feature_swing_modes": False, + "climate_feature_target_temperature": "high_low", + "climate_feature_target_humidity": False, + }, + { + "mode_command_topic": "mode-command-topic", + "mode_command_template": "{{ value }}", + "mode_state_topic": "mode-state-topic", + "mode_state_template": "{{ value_json.mode }}", + "modes": ["off", "heat", "cool"], + # high/low target temperature + "target_temperature_settings": { + "temperature_low_command_topic": "temperature-low-command-topic", + "temperature_low_command_template": "{{ value }}", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_low_state_template": "{{ value_json.temperature_low }}", + "temperature_high_command_topic": "temperature-high-command-topic", + "temperature_high_command_template": "{{ value }}", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_high_state_template": "{{ value_json.temperature_high }}", + "min_temp": 8, + "max_temp": 28, + "precision": "0.1", + "temp_step": 1.0, + "initial": 19.0, + }, + }, + {}, + {"entity_picture"}, + ), ], - ids=["notify", "sensor", "light_basic"], + ids=["notify", "sensor", "light_basic", "climate_single", "climate_high_low"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, From 70cfdfa231f169d61a4687d5fe912e22d40f3b99 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:23:54 +0200 Subject: [PATCH 0765/1118] Remove unnecessary CONFIG_SCHEMA from Uptime Kuma integration (#149601) --- homeassistant/components/uptime_kuma/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py index 4efe6a68193..cdeae16cc5a 100644 --- a/homeassistant/components/uptime_kuma/__init__.py +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -6,7 +6,7 @@ from pythonkuma.update import UpdateChecker from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.hass_dict import HassKey @@ -19,7 +19,6 @@ from .coordinator import ( _PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) UPTIME_KUMA_KEY: HassKey[UptimeKumaSoftwareUpdateCoordinator] = HassKey(DOMAIN) From a21af78aa1391e815dd360dbfd46acfca84a9b01 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:27:43 -0400 Subject: [PATCH 0766/1118] Add config flow to template light platform (#149448) --- .../components/template/config_flow.py | 33 ++++++++++ homeassistant/components/template/light.py | 50 ++++++++++++++- .../components/template/strings.json | 55 +++++++++++++++++ .../template/snapshots/test_light.ambr | 19 ++++++ tests/components/template/test_config_flow.py | 32 ++++++++++ tests/components/template/test_light.py | 61 ++++++++++++++++++- 6 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/components/template/snapshots/test_light.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 7963f525b7a..c9028d058bf 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -72,6 +72,15 @@ from .cover import ( STOP_ACTION, async_create_preview_cover, ) +from .light import ( + CONF_HS, + CONF_HS_ACTION, + CONF_LEVEL, + CONF_LEVEL_ACTION, + CONF_TEMPERATURE, + CONF_TEMPERATURE_ACTION, + async_create_preview_light, +) from .number import ( CONF_MAX, CONF_MIN, @@ -179,6 +188,18 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_VERIFY_SSL, default=True): selector.BooleanSelector(), } + if domain == Platform.LIGHT: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_TURN_ON): selector.ActionSelector(), + vol.Required(CONF_TURN_OFF): selector.ActionSelector(), + vol.Optional(CONF_LEVEL): selector.TemplateSelector(), + vol.Optional(CONF_LEVEL_ACTION): selector.ActionSelector(), + vol.Optional(CONF_HS): selector.TemplateSelector(), + vol.Optional(CONF_HS_ACTION): selector.ActionSelector(), + vol.Optional(CONF_TEMPERATURE): selector.TemplateSelector(), + vol.Optional(CONF_TEMPERATURE_ACTION): selector.ActionSelector(), + } + if domain == Platform.NUMBER: schema |= { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -359,6 +380,7 @@ TEMPLATE_TYPES = [ Platform.BUTTON, Platform.COVER, Platform.IMAGE, + Platform.LIGHT, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -391,6 +413,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), + Platform.LIGHT: SchemaFlowFormStep( + config_schema(Platform.LIGHT), + preview="template", + validate_user_input=validate_user_input(Platform.LIGHT), + ), Platform.NUMBER: SchemaFlowFormStep( config_schema(Platform.NUMBER), preview="template", @@ -440,6 +467,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), + Platform.LIGHT: SchemaFlowFormStep( + options_schema(Platform.LIGHT), + preview="template", + validate_user_input=validate_user_input(Platform.LIGHT), + ), Platform.NUMBER: SchemaFlowFormStep( options_schema(Platform.NUMBER), preview="template", @@ -469,6 +501,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, Platform.COVER: async_create_preview_cover, + Platform.LIGHT: async_create_preview_light, Platform.NUMBER: async_create_preview_number, Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 19eecaa7006..538d3f3aaaf 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -27,6 +27,7 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EFFECT, CONF_ENTITY_ID, @@ -43,15 +44,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, @@ -135,6 +144,8 @@ LIGHT_COMMON_SCHEMA = vol.Schema( vol.Optional(CONF_MIN_MIREDS): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_RGB): cv.template, vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, @@ -195,6 +206,10 @@ PLATFORM_SCHEMA = vol.All( ), ) +LIGHT_CONFIG_ENTRY_SCHEMA = LIGHT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -216,6 +231,37 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateLightEntity, + LIGHT_CONFIG_ENTRY_SCHEMA, + True, + ) + + +@callback +def async_create_preview_light( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateLightEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateLightEntity, + LIGHT_CONFIG_ENTRY_SCHEMA, + True, + ) + + class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 36bca174ef6..070dd75865f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -131,6 +131,33 @@ }, "title": "Template image" }, + "light": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "level": "Brightness level", + "set_level": "Actions on set level", + "hs": "HS color", + "set_hs": "Actions on set HS color", + "temperature": "Color temperature", + "set_temperature": "Actions on set color temperature" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template light" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -206,6 +233,7 @@ "button": "Template a button", "cover": "Template a cover", "image": "Template an image", + "light": "Template a light", "number": "Template a number", "select": "Template a select", "sensor": "Template a sensor", @@ -351,6 +379,33 @@ }, "title": "[%key:component::template::config::step::image::title%]" }, + "light": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "level": "[%key:component::template::config::step::light::data::level%]", + "set_level": "[%key:component::template::config::step::light::data::set_level%]", + "hs": "[%key:component::template::config::step::light::data::hs%]", + "set_hs": "[%key:component::template::config::step::light::data::set_hs%]", + "temperature": "[%key:component::template::config::step::light::data::temperature%]", + "set_temperature": "[%key:component::template::config::step::light::data::set_temperature%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "[%key:component::template::config::step::light::title%]" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", diff --git a/tests/components/template/snapshots/test_light.ambr b/tests/components/template/snapshots/test_light.ambr new file mode 100644 index 00000000000..0740d56a72e --- /dev/null +++ b/tests/components/template/snapshots/test_light.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'My template', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 8d7f2e6d89c..ad992eec79d 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -159,6 +159,16 @@ BINARY_SENSOR_OPTIONS = { {"verify_ssl": True}, {}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + "on", + {"one": "on", "two": "off"}, + {}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + {}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -330,6 +340,12 @@ async def test_config_flow( {"verify_ssl": True}, {"verify_ssl": True}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -535,6 +551,16 @@ async def test_config_flow_device( }, "url", ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"state": "{{ states('light.two') }}"}, + ["on", "off"], + {"one": "on", "two": "off"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + "state", + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -1374,6 +1400,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "light", + {"state": "{{ states('light.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b42eba0665d..0549f9981e7 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import light, template from homeassistant.components.light import ( @@ -30,9 +31,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator # Represent for light's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" @@ -2791,3 +2793,58 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a light from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "on", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "turn_on": [], + "turn_off": [], + "template_type": light.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("light.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + light.DOMAIN, + { + "name": "My template", + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ) + + assert state["state"] == STATE_ON From 91be25a292f96b5430b6d8c47a60d7c11076f167 Mon Sep 17 00:00:00 2001 From: lucasfijen Date: Wed, 30 Jul 2025 15:43:10 +0200 Subject: [PATCH 0767/1118] Add get recipes search service to Mealie integration (#149348) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/mealie/const.py | 2 + homeassistant/components/mealie/icons.json | 3 + homeassistant/components/mealie/services.py | 39 + homeassistant/components/mealie/services.yaml | 21 + homeassistant/components/mealie/strings.json | 21 + tests/components/mealie/conftest.py | 3 + .../mealie/fixtures/get_recipes.json | 1692 +++++++++++++++++ .../mealie/snapshots/test_services.ambr | 1238 ++++++++++++ tests/components/mealie/test_services.py | 60 + 9 files changed, 3079 insertions(+) create mode 100644 tests/components/mealie/fixtures/get_recipes.json diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index c040d665794..481cc4ccb7d 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -17,5 +17,7 @@ ATTR_INCLUDE_TAGS = "include_tags" ATTR_ENTRY_TYPE = "entry_type" ATTR_NOTE_TITLE = "note_title" ATTR_NOTE_TEXT = "note_text" +ATTR_SEARCH_TERMS = "search_terms" +ATTR_RESULT_LIMIT = "result_limit" MIN_REQUIRED_MEALIE_VERSION = AwesomeVersion("v1.0.0") diff --git a/homeassistant/components/mealie/icons.json b/homeassistant/components/mealie/icons.json index d7e29cc8bbe..773d70afa5f 100644 --- a/homeassistant/components/mealie/icons.json +++ b/homeassistant/components/mealie/icons.json @@ -30,6 +30,9 @@ "get_recipe": { "service": "mdi:map" }, + "get_recipes": { + "service": "mdi:book-open-page-variant" + }, "import_recipe": { "service": "mdi:map-search" }, diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 0d9a29392a4..f219cea1835 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -32,6 +32,8 @@ from .const import ( ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, + ATTR_RESULT_LIMIT, + ATTR_SEARCH_TERMS, ATTR_START_DATE, ATTR_URL, DOMAIN, @@ -55,6 +57,15 @@ SERVICE_GET_RECIPE_SCHEMA = vol.Schema( } ) +SERVICE_GET_RECIPES = "get_recipes" +SERVICE_GET_RECIPES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_SEARCH_TERMS): str, + vol.Optional(ATTR_RESULT_LIMIT): int, + } +) + SERVICE_IMPORT_RECIPE = "import_recipe" SERVICE_IMPORT_RECIPE_SCHEMA = vol.Schema( { @@ -159,6 +170,27 @@ async def _async_get_recipe(call: ServiceCall) -> ServiceResponse: return {"recipe": asdict(recipe)} +async def _async_get_recipes(call: ServiceCall) -> ServiceResponse: + """Get recipes.""" + entry = _async_get_entry(call) + search_terms = call.data.get(ATTR_SEARCH_TERMS) + result_limit = call.data.get(ATTR_RESULT_LIMIT, 10) + client = entry.runtime_data.client + try: + recipes = await client.get_recipes(search=search_terms, per_page=result_limit) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_recipes_found", + ) from err + return {"recipes": asdict(recipes)} + + async def _async_import_recipe(call: ServiceCall) -> ServiceResponse: """Import a recipe.""" entry = _async_get_entry(call) @@ -242,6 +274,13 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_RECIPE_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_RECIPES, + _async_get_recipes, + schema=SERVICE_GET_RECIPES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_IMPORT_RECIPE, diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 47a79ba5756..6a78564a578 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -24,6 +24,27 @@ get_recipe: selector: text: +get_recipes: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mealie + search_terms: + required: false + selector: + text: + result_limit: + required: false + default: 10 + selector: + number: + min: 1 + max: 100 + mode: box + unit_of_measurement: recipes + import_recipe: fields: config_entry_id: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 186fc4c4ac0..5533631f755 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -109,6 +109,9 @@ "recipe_not_found": { "message": "Recipe with ID or slug `{recipe_id}` not found." }, + "no_recipes_found": { + "message": "No recipes found matching your search." + }, "could_not_import_recipe": { "message": "Mealie could not import the recipe from the URL." }, @@ -176,6 +179,24 @@ } } }, + "get_recipes": { + "name": "Get recipes", + "description": "Searches for recipes with any matching properties in Mealie", + "fields": { + "config_entry_id": { + "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", + "description": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::description%]" + }, + "search_terms": { + "name": "Search terms", + "description": "Terms to search for in recipe properties." + }, + "result_limit": { + "name": "Result limit", + "description": "Maximum number of recipes to return (default: 10)." + } + } + }, "import_recipe": { "name": "Import recipe", "description": "Imports a recipe from an URL", diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py index 8e724e4d8ea..422b1c3de44 100644 --- a/tests/components/mealie/conftest.py +++ b/tests/components/mealie/conftest.py @@ -8,6 +8,7 @@ from aiomealie import ( Mealplan, MealplanResponse, Recipe, + RecipesResponse, ShoppingItemsResponse, ShoppingListsResponse, Statistics, @@ -63,6 +64,8 @@ def mock_mealie_client() -> Generator[AsyncMock]: ) recipe = Recipe.from_json(load_fixture("get_recipe.json", DOMAIN)) client.get_recipe.return_value = recipe + recipes = RecipesResponse.from_json(load_fixture("get_recipes.json", DOMAIN)) + client.get_recipes.return_value = recipes client.import_recipe.return_value = recipe client.get_shopping_lists.return_value = ShoppingListsResponse.from_json( load_fixture("get_shopping_lists.json", DOMAIN) diff --git a/tests/components/mealie/fixtures/get_recipes.json b/tests/components/mealie/fixtures/get_recipes.json new file mode 100644 index 00000000000..8ee91a1aa0e --- /dev/null +++ b/tests/components/mealie/fixtures/get_recipes.json @@ -0,0 +1,1692 @@ +{ + "page": 1, + "per_page": 50, + "total": 662, + "total_pages": 14, + "items": [ + { + "id": "e82f5449-c33b-437c-b712-337587199264", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "tu6y", + "slug": "tu6y", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T11:10:14.866359", + "createdAt": "2024-01-21T11:10:14.880721", + "updateAt": "2024-01-21T11:10:14.880723", + "lastMade": null + }, + { + "id": "f79f7e9d-4b58-4930-a586-2b127f16ee34", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)", + "slug": "eukole-makaronada-me-kephtedakia-ston-phourno-1", + "image": "En9o", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "50 Minutes", + "description": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T09:08:58.056854", + "createdAt": "2024-01-21T09:08:58.059401", + "updateAt": "2024-01-21T09:08:58.059403", + "lastMade": null + }, + { + "id": "90097c8b-9d80-468a-b497-73957ac0cd8b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Patates douces au four (1)", + "slug": "patates-douces-au-four-1", + "image": "aAhk", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T10:27:39.409746", + "createdAt": "2024-01-21T09:08:53.846294", + "updateAt": "2024-01-21T09:08:53.846295", + "lastMade": null + }, + { + "id": "98845807-9365-41fd-acd1-35630b468c27", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Sweet potatoes", + "slug": "sweet-potatoes", + "image": "kdhm", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T10:28:05.977615", + "createdAt": "2024-01-21T09:08:53.846294", + "updateAt": "2024-01-21T09:08:53.846295", + "lastMade": null + }, + { + "id": "40c227e0-3c7e-41f7-866d-5de04eaecdd7", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο", + "slug": "eukole-makaronada-me-kephtedakia-ston-phourno", + "image": "tNbG", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "50 Minutes", + "description": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T09:06:44.015829", + "createdAt": "2024-01-21T09:06:44.019650", + "updateAt": "2024-01-21T09:06:44.019653", + "lastMade": null + }, + { + "id": "9c7b8aee-c93c-4b1b-ab48-2625d444743a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Boeuf bourguignon : la vraie recette (2)", + "slug": "boeuf-bourguignon-la-vraie-recette-2", + "image": "nj5M", + "recipeYield": "4 servings", + "totalTime": "5 Hours", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "4 Hours", + "description": "bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre", + "recipeCategory": [], + "tags": [ + { + "id": "01c2f4ac-54ce-49bc-9bd7-8a49f353a3a4", + "name": "Poivre", + "slug": "poivre" + }, + { + "id": "90a26cea-a8a1-41a1-9e8c-e94e3c40f7a7", + "name": "Sel", + "slug": "sel" + }, + { + "id": "d7b01a4b-5206-4bd2-b9c4-d13b95ca0edb", + "name": "Beurre", + "slug": "beurre" + }, + { + "id": "304faaf8-13ec-4537-91f3-9f39a3585545", + "name": "Facile", + "slug": "facile" + }, + { + "id": "6508fb05-fb60-4bed-90c4-584bd6d74cb5", + "name": "Daube", + "slug": "daube" + }, + { + "id": "18ff59b6-b599-456a-896b-4b76448b08ca", + "name": "Bourguignon", + "slug": "bourguignon" + }, + { + "id": "685a0d90-8de4-494e-8eb8-68e7f5d5ffbe", + "name": "Vin Rouge", + "slug": "vin-rouge" + }, + { + "id": "5dedc8b5-30f5-4d6e-875f-34deefd01883", + "name": "Oignon", + "slug": "oignon" + }, + { + "id": "065b79e0-6276-4ebb-9428-7018b40c55bb", + "name": "Bouquet Garni", + "slug": "bouquet-garni" + }, + { + "id": "d858b1d9-2ca1-46d4-acc2-3d03f991f03f", + "name": "Moyen", + "slug": "moyen" + }, + { + "id": "bded0bd8-8d41-4ec5-ad73-e0107fb60908", + "name": "Boeuf Bourguignon : La Vraie Recette", + "slug": "boeuf-bourguignon-la-vraie-recette" + }, + { + "id": "7f99b04f-914a-408b-a057-511ca1125734", + "name": "Carotte", + "slug": "carotte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:45:28.780361", + "createdAt": "2024-01-21T08:45:28.782322", + "updateAt": "2024-01-21T08:45:28.782324", + "lastMade": null + }, + { + "id": "fc42c7d1-7b0f-4e04-b88a-dbd80b81540b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Boeuf bourguignon : la vraie recette (1)", + "slug": "boeuf-bourguignon-la-vraie-recette-1", + "image": "rbU7", + "recipeYield": "4 servings", + "totalTime": "5 Hours", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "4 Hours", + "description": "bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre", + "recipeCategory": [], + "tags": [ + { + "id": "01c2f4ac-54ce-49bc-9bd7-8a49f353a3a4", + "name": "Poivre", + "slug": "poivre" + }, + { + "id": "90a26cea-a8a1-41a1-9e8c-e94e3c40f7a7", + "name": "Sel", + "slug": "sel" + }, + { + "id": "d7b01a4b-5206-4bd2-b9c4-d13b95ca0edb", + "name": "Beurre", + "slug": "beurre" + }, + { + "id": "304faaf8-13ec-4537-91f3-9f39a3585545", + "name": "Facile", + "slug": "facile" + }, + { + "id": "6508fb05-fb60-4bed-90c4-584bd6d74cb5", + "name": "Daube", + "slug": "daube" + }, + { + "id": "18ff59b6-b599-456a-896b-4b76448b08ca", + "name": "Bourguignon", + "slug": "bourguignon" + }, + { + "id": "685a0d90-8de4-494e-8eb8-68e7f5d5ffbe", + "name": "Vin Rouge", + "slug": "vin-rouge" + }, + { + "id": "5dedc8b5-30f5-4d6e-875f-34deefd01883", + "name": "Oignon", + "slug": "oignon" + }, + { + "id": "065b79e0-6276-4ebb-9428-7018b40c55bb", + "name": "Bouquet Garni", + "slug": "bouquet-garni" + }, + { + "id": "d858b1d9-2ca1-46d4-acc2-3d03f991f03f", + "name": "Moyen", + "slug": "moyen" + }, + { + "id": "bded0bd8-8d41-4ec5-ad73-e0107fb60908", + "name": "Boeuf Bourguignon : La Vraie Recette", + "slug": "boeuf-bourguignon-la-vraie-recette" + }, + { + "id": "7f99b04f-914a-408b-a057-511ca1125734", + "name": "Carotte", + "slug": "carotte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:43:36.105722", + "createdAt": "2024-01-21T08:43:36.108116", + "updateAt": "2024-01-21T08:43:36.108118", + "lastMade": null + }, + { + "id": "89e63d72-7a51-4cef-b162-2e45035d0a91", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Veganes Marmor-Bananenbrot mit Erdnussbutter", + "slug": "veganes-marmor-bananenbrot-mit-erdnussbutter", + "image": "JSp3", + "recipeYield": "14 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "55 Minutes", + "description": "Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:28:11.008440", + "createdAt": "2024-01-21T08:28:11.011427", + "updateAt": "2024-01-21T08:28:11.011428", + "lastMade": null + }, + { + "id": "eab64457-97ba-4d6c-871c-cb1c724ccb51", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin", + "slug": "pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin", + "image": "9QMh", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:24:50.952774", + "createdAt": "2024-01-21T08:24:50.955843", + "updateAt": "2024-01-21T08:24:50.955845", + "lastMade": null + }, + { + "id": "12439e3d-3c1c-4dcc-9c6e-4afcea2a0542", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test123", + "slug": "test123", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:00:02.755328", + "createdAt": "2024-01-21T08:00:02.757103", + "updateAt": "2024-01-21T08:00:02.757105", + "lastMade": null + }, + { + "id": "6567f6ec-e410-49cb-a1a5-d08517184e78", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Bureeto", + "slug": "bureeto", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:37:39.940578", + "createdAt": "2024-01-21T07:37:39.942535", + "updateAt": "2024-01-21T07:37:39.942537", + "lastMade": null + }, + { + "id": "f7737d17-161c-4008-88d4-dd2616778cd0", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Subway Double Cookies", + "slug": "subway-double-cookies", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:34:53.944858", + "createdAt": "2024-01-21T07:34:53.946852", + "updateAt": "2024-01-21T07:34:53.946854", + "lastMade": null + }, + { + "id": "1904b717-4a8b-4de9-8909-56958875b5f4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "qwerty12345", + "slug": "qwerty12345", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:37:55.795675", + "createdAt": "2024-01-21T07:28:05.395272", + "updateAt": "2024-01-21T07:28:05.395274", + "lastMade": null + }, + { + "id": "8bdd3656-5e7e-45d3-a3c4-557390846a22", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Cheeseburger Sliders (Easy, 30-min Recipe)", + "slug": "cheeseburger-sliders-easy-30-min-recipe", + "image": "beGq", + "recipeYield": "24 servings", + "totalTime": "30 Minutes", + "prepTime": "8 Minutes", + "cookTime": null, + "performTime": "22 Minutes", + "description": "Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.", + "recipeCategory": [], + "tags": [ + { + "id": "7a4ca427-642f-4428-8dc7-557ea9c8d1b4", + "name": "Cheeseburger Sliders", + "slug": "cheeseburger-sliders" + }, + { + "id": "941558d2-50d5-4c9d-8890-a0258f18d493", + "name": "Sliders", + "slug": "sliders" + } + ], + "tools": [], + "rating": 5, + "orgURL": "https://natashaskitchen.com/cheeseburger-sliders/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:43:24.261010", + "createdAt": "2024-01-21T06:49:35.466777", + "updateAt": "2024-01-21T06:49:35.466778", + "lastMade": "2024-01-22T04:59:59" + }, + { + "id": "8a30d31d-aa14-411e-af0c-6b61a94f5291", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "meatloaf", + "slug": "meatloaf", + "image": null, + "recipeYield": "4", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T06:37:09.426467", + "createdAt": "2024-01-21T06:36:57.645658", + "updateAt": "2024-01-21T06:37:09.428351", + "lastMade": null + }, + { + "id": "f2f7880b-1136-436f-91b7-129788d8c117", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Richtig rheinischer Sauerbraten", + "slug": "richtig-rheinischer-sauerbraten", + "image": "kCBh", + "recipeYield": "4 servings", + "totalTime": "3 Hours 20 Minutes", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "2 Hours 20 Minutes", + "description": "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": 3, + "orgURL": "https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T05:37:55.419788", + "createdAt": "2024-01-21T05:24:03.402973", + "updateAt": "2024-01-21T05:37:55.422471", + "lastMade": null + }, + { + "id": "cf634591-0f82-4254-8e00-2f7e8b0c9022", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Orientalischer Gemüse-Hähnchen Eintopf", + "slug": "orientalischer-gemuse-hahnchen-eintopf", + "image": "kpBx", + "recipeYield": "6 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [ + { + "id": "518f3081-a919-4c80-9cad-75ffbd0e73d3", + "name": "Gemüse", + "slug": "gemuse" + }, + { + "id": "a3fff625-1902-4112-b169-54aec4f52ea7", + "name": "Hauptspeise", + "slug": "hauptspeise" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "1f87d43d-7d9d-4806-993a-fdb89117d64e", + "name": "Fleisch", + "slug": "fleisch" + }, + { + "id": "7caa64df-c65d-4fb0-9075-b788e6a05e1d", + "name": "Geflügel", + "slug": "geflugel" + }, + { + "id": "38d18d57-d817-491e-94f8-da923d2c540e", + "name": "Eintopf", + "slug": "eintopf" + }, + { + "id": "398fbd98-4175-4652-92a4-51e55482dc9b", + "name": "Schmoren", + "slug": "schmoren" + }, + { + "id": "ec303c13-a4f7-4de3-8a4f-d13b72ddd500", + "name": "Hülsenfrüchte", + "slug": "hulsenfruchte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:58:54.661618", + "createdAt": "2024-01-21T04:58:54.665601", + "updateAt": "2024-01-21T04:58:54.665603", + "lastMade": null + }, + { + "id": "05208856-d273-4cc9-bcfa-e0215d57108d", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 20240121", + "slug": "test-20240121", + "image": null, + "recipeYield": "4", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:56:20.569413", + "createdAt": "2024-01-21T04:55:49.820247", + "updateAt": "2024-01-21T04:56:20.571564", + "lastMade": null + }, + { + "id": "145eeb05-781a-4eb0-a656-afa8bc8c0164", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Loempia bowl", + "slug": "loempia-bowl", + "image": "McEx", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.lekkerensimpel.com/loempia-bowl/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:39:48.558572", + "createdAt": "2024-01-21T04:39:48.560422", + "updateAt": "2024-01-21T04:39:48.560424", + "lastMade": null + }, + { + "id": "5c6532aa-ad84-424c-bc05-c32d50430fe4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "5 Ingredient Chocolate Mousse", + "slug": "5-ingredient-chocolate-mousse", + "image": "bzqo", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": null, + "description": "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://thehappypear.ie/aquafaba-chocolate-mousse/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T06:06:26.305680", + "createdAt": "2024-01-21T04:14:34.624708", + "updateAt": "2024-01-21T06:06:26.308017", + "lastMade": null + }, + { + "id": "f2e684f2-49e0-45ee-90de-951344472f1c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Der perfekte Pfannkuchen - gelingt einfach immer", + "slug": "der-perfekte-pfannkuchen-gelingt-einfach-immer", + "image": "KGK6", + "recipeYield": "4 servings", + "totalTime": "15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "10 Minutes", + "description": "Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [ + { + "id": "4ec445c6-fc2f-4a1e-b666-93435a46ec42", + "name": "Schnell", + "slug": "schnell" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "66bc0f60-ff95-44e4-afef-8437b2c2d9af", + "name": "Backen", + "slug": "backen" + }, + { + "id": "48d2a71c-ed17-4c07-bf9f-bc9216936f54", + "name": "Kuchen", + "slug": "kuchen" + }, + { + "id": "b2821b25-94ea-4576-b488-276331b3d76e", + "name": "Kinder", + "slug": "kinder" + }, + { + "id": "fee5e626-792c-479d-a265-81a0029047f2", + "name": "Mehlspeisen", + "slug": "mehlspeisen" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:06:40.503968", + "createdAt": "2024-01-21T04:04:43.296547", + "updateAt": "2024-01-21T04:06:40.506886", + "lastMade": null + }, + { + "id": "cf239441-b75d-4dea-a48e-9d99b7cb5842", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Dinkel-Sauerteigbrot", + "slug": "dinkel-sauerteigbrot", + "image": "yNDq", + "recipeYield": "1", + "totalTime": "24h", + "prepTime": "1h", + "cookTime": null, + "performTime": "35min", + "description": "Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.", + "recipeCategory": [ + { + "id": "6d54ca14-eb71-4d3a-933d-5e88f68edb68", + "name": "Brot", + "slug": "brot" + } + ], + "tags": [ + { + "id": "0f80c5d5-d1ee-41ac-a949-54a76b446459", + "name": "Sourdough", + "slug": "sourdough" + } + ], + "tools": [ + { + "id": "1170e609-20d3-45b8-b0c7-3a4cfa614e88", + "name": "Backofen", + "slug": "backofen", + "onHand": false + } + ], + "rating": null, + "orgURL": "https://www.besondersgut.ch/dinkel-sauerteigbrot/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:57:41.588112", + "createdAt": "2024-01-21T03:44:30.512149", + "updateAt": "2024-01-21T03:44:30.512151", + "lastMade": null + }, + { + "id": "2673eb90-6d78-4b95-af36-5db8c8a6da37", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 234234", + "slug": "test-234234", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T04:07:55.643655", + "createdAt": "2024-01-21T03:14:59.852966", + "updateAt": "2024-01-21T04:07:55.646291", + "lastMade": null + }, + { + "id": "0a723c54-af53-40e9-a15f-c87aae5ac688", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "test 243", + "slug": "test-243", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T02:20:32.570339", + "createdAt": "2024-01-21T02:20:32.572744", + "updateAt": "2024-01-21T02:20:32.572746", + "lastMade": null + }, + { + "id": "9d553779-607e-471b-acf3-84e6be27b159", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Einfacher Nudelauflauf mit Brokkoli", + "slug": "einfacher-nudelauflauf-mit-brokkoli", + "image": "nOPT", + "recipeYield": "4 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:04:25.718367", + "createdAt": "2024-01-21T02:13:11.323363", + "updateAt": "2024-01-21T03:04:25.721489", + "lastMade": null + }, + { + "id": "9d3cb303-a996-4144-948a-36afaeeef554", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tarta cytrynowa z bezą", + "slug": "tarta-cytrynowa-z-beza", + "image": "vxuL", + "recipeYield": "8 servings", + "totalTime": "1 Hour", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": null, + "description": "Tarta cytrynowa z bezą\r\nLekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko.\r\nDla kogo?\r\nLubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem!\r\nNa jaką okazję?\r\nNa rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby.\r\nCzy wiesz, że?\r\nZastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku.\r\nDla urozmaicenia:\r\nMartwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:27:12.082247", + "createdAt": "2024-01-21T01:27:12.088594", + "updateAt": "2024-01-21T01:27:12.088596", + "lastMade": null + }, + { + "id": "77f05a49-e869-4048-aa62-0d8a1f5a8f1c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Martins test Recipe", + "slug": "martins-test-recipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:26:38.793372", + "createdAt": "2024-01-21T01:26:38.802872", + "updateAt": "2024-01-21T01:26:38.802874", + "lastMade": null + }, + { + "id": "75a90207-9c10-4390-a265-c47a4b67fd69", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Muffinki czekoladowe", + "slug": "muffinki-czekoladowe", + "image": "xP1Q", + "recipeYield": "12", + "totalTime": null, + "prepTime": "25 Minutes", + "cookTime": null, + "performTime": "30 Minutes", + "description": "Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.", + "recipeCategory": [], + "tags": [ + { + "id": "ed2eed99-1285-4507-b5cb-b3047d64855c", + "name": "Muffinki Czekoladowe", + "slug": "muffinki-czekoladowe" + }, + { + "id": "e94d5223-5337-4e1b-b36e-7968c8823176", + "name": "Babeczki I Muffiny", + "slug": "babeczki-i-muffiny" + }, + { + "id": "2d06a44a-331a-4922-abb4-8047ee5e7c1c", + "name": "Sylwester", + "slug": "sylwester" + }, + { + "id": "c78edd8c-c96b-43fb-86c0-917ea5a08ac7", + "name": "Wegetariańska", + "slug": "wegetarianska" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://aniagotuje.pl/przepis/muffinki-czekoladowe", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:25:53.529639", + "createdAt": "2024-01-21T01:25:03.838184", + "updateAt": "2024-01-21T01:25:53.534515", + "lastMade": null + }, + { + "id": "4320ba72-377b-4657-8297-dce198f24cdf", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "My Test Recipe", + "slug": "my-test-recipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:22:10.331488", + "createdAt": "2024-01-21T01:22:10.361617", + "updateAt": "2024-01-21T01:22:10.361618", + "lastMade": null + }, + { + "id": "98dac844-31ee-426a-b16c-fb62a5dd2816", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "My Test Receipe", + "slug": "my-test-receipe", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T01:22:10.309993", + "createdAt": "2024-01-21T01:22:10.357806", + "updateAt": "2024-01-21T01:22:10.357807", + "lastMade": null + }, + { + "id": "c3c8f207-c704-415d-81b1-da9f032cf52f", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Patates douces au four", + "slug": "patates-douces-au-four", + "image": "r1ck", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T00:34:57.419501", + "createdAt": "2024-01-21T00:34:57.422137", + "updateAt": "2024-01-21T00:34:57.422139", + "lastMade": null + }, + { + "id": "1edb2f6e-133c-4be0-b516-3c23625a97ec", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Easy Homemade Pizza Dough", + "slug": "easy-homemade-pizza-dough", + "image": "gD94", + "recipeYield": "2 servings", + "totalTime": "2 Hours 30 Minutes", + "prepTime": "2 Hours 15 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T22:41:09.255367", + "createdAt": "2024-01-20T22:41:09.258070", + "updateAt": "2024-01-20T22:41:09.258071", + "lastMade": null + }, + { + "id": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "All-American Beef Stew Recipe", + "slug": "all-american-beef-stew-recipe", + "image": "356X", + "recipeYield": "6 servings", + "totalTime": "3 Hours 15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "3 Hours 10 Minutes", + "description": "This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.seriouseats.com/all-american-beef-stew-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T03:04:45.606075", + "createdAt": "2024-01-20T20:41:29.266390", + "updateAt": "2024-01-21T03:04:45.609563", + "lastMade": null + }, + { + "id": "6530ea6e-401e-4304-8a7a-12162ddf5b9c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + "slug": "serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce", + "image": "4Sys", + "recipeYield": "4 servings", + "totalTime": "2 Hours 15 Minutes", + "prepTime": "20 Minutes", + "cookTime": null, + "performTime": "55 Minutes", + "description": "This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.", + "recipeCategory": [], + "tags": [ + { + "id": "d7aea128-0e7b-4e0c-a236-e500717701bb", + "name": "Rice", + "slug": "rice" + }, + { + "id": "1dd3541c-ed6b-4a25-b829-9a71358409ef", + "name": "Chicken", + "slug": "chicken" + }, + { + "id": "eb871b57-ea46-4cb5-88a5-98064514e593", + "name": "Chicken And Rice", + "slug": "chicken-and-rice" + }, + { + "id": "2b0a0ed2-e799-4ab2-8a24-d5ce15827a8e", + "name": "Cook The Book", + "slug": "cook-the-book" + }, + { + "id": "e6783087-0cee-4f31-b588-268380f75335", + "name": "Halal", + "slug": "halal" + }, + { + "id": "a2d99845-8bd0-4a2a-9a56-f8a34f51039e", + "name": "Middle Eastern", + "slug": "middle-eastern" + }, + { + "id": "6b7b95b0-b3f8-467f-857d-ef036009d5e1", + "name": "New York City", + "slug": "new-york-city" + }, + { + "id": "6bd6c577-9d00-411f-88de-b8679c37ac58", + "name": "Serious Eats Book", + "slug": "serious-eats-book" + }, + { + "id": "d77a2071-43ae-40b1-854d-ae995a766fba", + "name": "Street Food", + "slug": "street-food" + } + ], + "tools": [], + "rating": 5, + "orgURL": "https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T20:32:14.736668", + "createdAt": "2024-01-20T20:25:43.655397", + "updateAt": "2024-01-20T20:32:14.740947", + "lastMade": null + }, + { + "id": "c496cf9c-1ece-448a-9d3f-ef772f078a4e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Schnelle Käsespätzle", + "slug": "schnelle-kasespatzle", + "image": "8goY", + "recipeYield": "4 servings", + "totalTime": "40 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "30 Minutes", + "description": "Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T18:31:51.652135", + "createdAt": "2024-01-20T18:31:51.654414", + "updateAt": "2024-01-20T18:31:51.654415", + "lastMade": null + }, + { + "id": "49aa6f42-6760-4adf-b6cd-59592da485c3", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "taco", + "slug": "taco", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T17:25:27.960087", + "createdAt": "2024-01-20T17:25:27.961639", + "updateAt": "2024-01-20T17:25:27.961641", + "lastMade": null + }, + { + "id": "6402a253-2baa-460d-bf4f-b759bb655588", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Vodkapasta", + "slug": "vodkapasta", + "image": "z8BB", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ica.se/recept/vodkapasta-729011/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T01:58:25.398326", + "createdAt": "2024-01-20T15:35:35.492234", + "updateAt": "2024-01-21T01:58:25.400556", + "lastMade": "2024-01-21T22:59:59" + }, + { + "id": "4f54e9e1-f21d-40ec-a135-91e633dfb733", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Vodkapasta2", + "slug": "vodkapasta2", + "image": "Nqpz", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ica.se/recept/vodkapasta-729011/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T17:35:32.077132", + "createdAt": "2024-01-20T15:35:35.492234", + "updateAt": "2024-01-20T17:24:19.620474", + "lastMade": "2024-01-21T04:59:59" + }, + { + "id": "e1a3edb0-49a0-49a3-83e3-95554e932670", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Rub", + "slug": "rub", + "image": null, + "recipeYield": "1", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:55:15.172744", + "createdAt": "2024-01-20T13:53:34.298477", + "updateAt": "2024-01-20T13:55:15.174780", + "lastMade": null + }, + { + "id": "1a0f4e54-db5b-40f1-ab7e-166dab5f6523", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Banana Bread Chocolate Chip Cookies", + "slug": "banana-bread-chocolate-chip-cookies", + "image": "03XS", + "recipeYield": "", + "totalTime": null, + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + "recipeCategory": [], + "tags": [ + { + "id": "6a59e597-9aff-4716-961f-f236b93c34cc", + "name": "Cookies", + "slug": "cookies" + }, + { + "id": "1249f351-4b45-455d-b5f0-64eb0124a41e", + "name": "Banana", + "slug": "banana" + }, + { + "id": "81a446b9-4d8d-451d-a472-486987fad85a", + "name": "Bread", + "slug": "bread" + }, + { + "id": "c2536221-b1c3-4402-a104-46c632663748", + "name": "Chocolate Chip", + "slug": "chocolate-chip" + }, + { + "id": "c026c67f-0211-419f-9db8-7cd4c7608589", + "name": "Cookie", + "slug": "cookie" + }, + { + "id": "2f9e0bf5-02e2-4bdc-9b5d-a16d2fec885b", + "name": "American", + "slug": "american" + }, + { + "id": "2a7c5386-5d26-44fa-8a08-81747ee7f132", + "name": "Bake", + "slug": "bake" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:52:21.817496", + "createdAt": "2024-01-20T13:51:46.727976", + "updateAt": "2024-01-20T13:52:21.821329", + "lastMade": null + }, + { + "id": "447acae6-3424-4c16-8c26-c09040ad8041", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Cauliflower Bisque Recipe with Cheddar Cheese", + "slug": "cauliflower-bisque-recipe-with-cheddar-cheese", + "image": "KuXV", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:45:10.848270", + "createdAt": "2024-01-20T13:44:59.990057", + "updateAt": "2024-01-20T13:45:10.851647", + "lastMade": null + }, + { + "id": "864136a3-27b0-4f3b-a90f-486f42d6df7a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Prova ", + "slug": "prova", + "image": null, + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:44:41.788771", + "createdAt": "2024-01-20T13:42:56.178473", + "updateAt": "2024-01-20T13:42:56.178475", + "lastMade": null + }, + { + "id": "c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "pate au beurre (1)", + "slug": "pate-au-beurre-1", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:17:47.586659", + "createdAt": "2024-01-20T13:17:47.592852", + "updateAt": "2024-01-20T13:17:47.592854", + "lastMade": null + }, + { + "id": "d01865c3-0f18-4e8d-84c0-c14c345fdf9c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "pate au beurre", + "slug": "pate-au-beurre", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:16:49.702039", + "createdAt": "2024-01-20T13:16:49.704498", + "updateAt": "2024-01-20T13:16:49.704500", + "lastMade": null + }, + { + "id": "2cec2bb2-19b6-40b8-a36c-1a76ea29c517", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Sous Vide Cheesecake Recipe", + "slug": "sous-vide-cheesecake-recipe", + "image": "tmwm", + "recipeYield": "4 servings", + "totalTime": "2 Hours 10 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "1 Hour 30 Minutes", + "description": "Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://saltpepperskillet.com/recipes/sous-vide-cheesecake/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:07:19.939939", + "createdAt": "2024-01-20T13:07:19.946260", + "updateAt": "2024-01-20T13:07:19.946263", + "lastMade": null + }, + { + "id": "8e0e4566-9caf-4c2e-a01c-dcead23db86b", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "The Bomb Mini Cheesecakes", + "slug": "the-bomb-mini-cheesecakes", + "image": "xCYc", + "recipeYield": "10 servings", + "totalTime": "1 Hour 30 Minutes", + "prepTime": "30 Minutes", + "cookTime": null, + "performTime": null, + "description": "This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:05:24.037000", + "createdAt": "2024-01-20T13:05:24.039558", + "updateAt": "2024-01-20T13:05:24.039560", + "lastMade": null + }, + { + "id": "a051eafd-9712-4aee-a8e5-0cd10a6772ee", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tagliatelle al Salmone", + "slug": "tagliatelle-al-salmone", + "image": "qzaN", + "recipeYield": "4 servings", + "totalTime": "25 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + "recipeCategory": [], + "tags": [ + { + "id": "518f3081-a919-4c80-9cad-75ffbd0e73d3", + "name": "Gemüse", + "slug": "gemuse" + }, + { + "id": "a3fff625-1902-4112-b169-54aec4f52ea7", + "name": "Hauptspeise", + "slug": "hauptspeise" + }, + { + "id": "4ec445c6-fc2f-4a1e-b666-93435a46ec42", + "name": "Schnell", + "slug": "schnell" + }, + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "6f349f84-655b-4740-8fa6-ed2716f17df7", + "name": "Gekocht", + "slug": "gekocht" + }, + { + "id": "77bc190f-dc6d-440b-aa82-f32bfe836018", + "name": "Europa", + "slug": "europa" + }, + { + "id": "7997c911-14ee-4e76-9895-debad7949ae2", + "name": "Pasta", + "slug": "pasta" + }, + { + "id": "04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9", + "name": "Nudeln", + "slug": "nudeln" + }, + { + "id": "c56cd402-3ac7-479e-b96c-d4b64d177dd3", + "name": "Fisch", + "slug": "fisch" + }, + { + "id": "88015586-0885-4397-9098-039ae1109cd1", + "name": "Italien", + "slug": "italien" + }, + { + "id": "024b30ca-53cb-4243-ba6b-d830610f2f48", + "name": "Saucen", + "slug": "saucen" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:02:16.760030", + "createdAt": "2024-01-20T13:02:16.763188", + "updateAt": "2024-01-20T13:02:16.763189", + "lastMade": null + }, + { + "id": "093d51e9-0823-40ad-8e0e-a1d5790dd627", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Death by Chocolate", + "slug": "death-by-chocolate", + "image": "K9qP", + "recipeYield": "1 serving", + "totalTime": null, + "prepTime": "25 Minutes", + "cookTime": null, + "performTime": "25 Minutes", + "description": "Hier ist der Name Programm: Den \"Tod durch Schokolade\" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T12:58:50.926224", + "createdAt": "2024-01-20T12:58:50.928810", + "updateAt": "2024-01-20T12:58:50.928812", + "lastMade": null + }, + { + "id": "2d1f62ec-4200-4cfd-987e-c75755d7607c", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Palak Dal Rezept aus Indien", + "slug": "palak-dal-rezept-aus-indien", + "image": "jKQ3", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.", + "recipeCategory": [], + "tags": [ + { + "id": "38d18d57-d817-491e-94f8-da923d2c540e", + "name": "Eintopf", + "slug": "eintopf" + }, + { + "id": "43f12acf-a8df-45bd-b33d-20bfe7a7e607", + "name": "Indisch", + "slug": "indisch" + }, + { + "id": "ede834ac-ab8f-4c79-8a42-dfa0270fd18b", + "name": "Linsen", + "slug": "linsen" + }, + { + "id": "2b6283e2-b8e0-4b3d-90d9-66f322ca77aa", + "name": "Spinat", + "slug": "spinat" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T12:46:54.570376", + "createdAt": "2024-01-20T12:46:54.573341", + "updateAt": "2024-01-20T12:46:54.573342", + "lastMade": null + }, + { + "id": "973dc36d-1661-49b4-ad2d-0b7191034fb3", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "householdId": "cd2bb87f-5e4c-4dc6-8477-af9537200014", + "name": "Tortelline - á la Romana", + "slug": "tortelline-a-la-romana", + "image": "rkSn", + "recipeYield": "4 servings", + "totalTime": "30 Minutes", + "prepTime": "30 Minutes", + "cookTime": null, + "performTime": null, + "description": "Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!", + "recipeCategory": [], + "tags": [ + { + "id": "4c79c0b7-c2d0-415a-b5cf-138cfce92c7e", + "name": "Einfach", + "slug": "einfach" + }, + { + "id": "7997c911-14ee-4e76-9895-debad7949ae2", + "name": "Pasta", + "slug": "pasta" + }, + { + "id": "04d2aea8-fc9a-4f9b-9a87-8f15189ab6f9", + "name": "Nudeln", + "slug": "nudeln" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-20T13:44:42.215472", + "createdAt": "2024-01-20T12:29:47.825708", + "updateAt": "2024-01-20T13:44:42.218635", + "lastMade": "2024-01-21T20:59:59" + } + ], + "next": "/recipes?page=2&perPage=50&orderDirection=desc", + "previous": null +} diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 56626c7b5c4..257d685d8dc 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1,4 +1,1242 @@ # serializer version: 1 +# name: test_service_get_recipes[service_data0] + dict({ + 'recipes': dict({ + 'items': list([ + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'tu6y', + 'original_url': None, + 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', + 'recipe_yield': None, + 'slug': 'tu6y', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'En9o', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'aAhk', + 'name': 'Patates douces au four (1)', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kdhm', + 'name': 'Sweet potatoes', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', + 'recipe_yield': '', + 'slug': 'sweet-potatoes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tNbG', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nj5M', + 'name': 'Boeuf bourguignon : la vraie recette (2)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rbU7', + 'name': 'Boeuf bourguignon : la vraie recette (1)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'JSp3', + 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', + 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_yield': '14 servings', + 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '9QMh', + 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', + 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_yield': '', + 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test123', + 'original_url': None, + 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_yield': None, + 'slug': 'test123', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Bureeto', + 'original_url': None, + 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_yield': None, + 'slug': 'bureeto', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Subway Double Cookies', + 'original_url': None, + 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_yield': None, + 'slug': 'subway-double-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'qwerty12345', + 'original_url': None, + 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_yield': None, + 'slug': 'qwerty12345', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'beGq', + 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', + 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_yield': '24 servings', + 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'meatloaf', + 'original_url': None, + 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_yield': '4', + 'slug': 'meatloaf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kCBh', + 'name': 'Richtig rheinischer Sauerbraten', + 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_yield': '4 servings', + 'slug': 'richtig-rheinischer-sauerbraten', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kpBx', + 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', + 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_yield': '6 servings', + 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 20240121', + 'original_url': None, + 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_yield': '4', + 'slug': 'test-20240121', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'McEx', + 'name': 'Loempia bowl', + 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_yield': '', + 'slug': 'loempia-bowl', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'bzqo', + 'name': '5 Ingredient Chocolate Mousse', + 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_yield': '6 servings', + 'slug': '5-ingredient-chocolate-mousse', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KGK6', + 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', + 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_yield': '4 servings', + 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'yNDq', + 'name': 'Dinkel-Sauerteigbrot', + 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_yield': '1', + 'slug': 'dinkel-sauerteigbrot', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 234234', + 'original_url': None, + 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_yield': None, + 'slug': 'test-234234', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 243', + 'original_url': None, + 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_yield': None, + 'slug': 'test-243', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': ''' + Tarta cytrynowa z bezą + Lekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko. + Dla kogo? + Lubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem! + Na jaką okazję? + Na rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby. + Czy wiesz, że? + Zastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku. + Dla urozmaicenia: + Martwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół. + ''', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'vxuL', + 'name': 'Tarta cytrynowa z bezą', + 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_yield': '8 servings', + 'slug': 'tarta-cytrynowa-z-beza', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Martins test Recipe', + 'original_url': None, + 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_yield': None, + 'slug': 'martins-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xP1Q', + 'name': 'Muffinki czekoladowe', + 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_yield': '12', + 'slug': 'muffinki-czekoladowe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Recipe', + 'original_url': None, + 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_yield': None, + 'slug': 'my-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Receipe', + 'original_url': None, + 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_yield': None, + 'slug': 'my-test-receipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'r1ck', + 'name': 'Patates douces au four', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'gD94', + 'name': 'Easy Homemade Pizza Dough', + 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_yield': '2 servings', + 'slug': 'easy-homemade-pizza-dough', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '356X', + 'name': 'All-American Beef Stew Recipe', + 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_yield': '6 servings', + 'slug': 'all-american-beef-stew-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '4Sys', + 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_yield': '4 servings', + 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '8goY', + 'name': 'Schnelle Käsespätzle', + 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_yield': '4 servings', + 'slug': 'schnelle-kasespatzle', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'taco', + 'original_url': None, + 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_yield': None, + 'slug': 'taco', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'z8BB', + 'name': 'Vodkapasta', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'Nqpz', + 'name': 'Vodkapasta2', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Rub', + 'original_url': None, + 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_yield': '1', + 'slug': 'rub', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '03XS', + 'name': 'Banana Bread Chocolate Chip Cookies', + 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_yield': '', + 'slug': 'banana-bread-chocolate-chip-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KuXV', + 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', + 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_yield': '', + 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Prova ', + 'original_url': None, + 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_yield': '', + 'slug': 'prova', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre (1)', + 'original_url': None, + 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_yield': None, + 'slug': 'pate-au-beurre-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre', + 'original_url': None, + 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_yield': None, + 'slug': 'pate-au-beurre', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tmwm', + 'name': 'Sous Vide Cheesecake Recipe', + 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_yield': '4 servings', + 'slug': 'sous-vide-cheesecake-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xCYc', + 'name': 'The Bomb Mini Cheesecakes', + 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_yield': '10 servings', + 'slug': 'the-bomb-mini-cheesecakes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'qzaN', + 'name': 'Tagliatelle al Salmone', + 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_yield': '4 servings', + 'slug': 'tagliatelle-al-salmone', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Hier ist der Name Programm: Den "Tod durch Schokolade" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'K9qP', + 'name': 'Death by Chocolate', + 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_yield': '1 serving', + 'slug': 'death-by-chocolate', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'jKQ3', + 'name': 'Palak Dal Rezept aus Indien', + 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_yield': '4 servings', + 'slug': 'palak-dal-rezept-aus-indien', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rkSn', + 'name': 'Tortelline - á la Romana', + 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_yield': '4 servings', + 'slug': 'tortelline-a-la-romana', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), + }), + }) +# --- +# name: test_service_get_recipes[service_data1] + dict({ + 'recipes': dict({ + 'items': list([ + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'tu6y', + 'original_url': None, + 'recipe_id': 'e82f5449-c33b-437c-b712-337587199264', + 'recipe_yield': None, + 'slug': 'tu6y', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'En9o', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'aAhk', + 'name': 'Patates douces au four (1)', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kdhm', + 'name': 'Sweet potatoes', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': '98845807-9365-41fd-acd1-35630b468c27', + 'recipe_yield': '', + 'slug': 'sweet-potatoes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tNbG', + 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο', + 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', + 'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7', + 'recipe_yield': '6 servings', + 'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nj5M', + 'name': 'Boeuf bourguignon : la vraie recette (2)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rbU7', + 'name': 'Boeuf bourguignon : la vraie recette (1)', + 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', + 'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b', + 'recipe_yield': '4 servings', + 'slug': 'boeuf-bourguignon-la-vraie-recette-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Dieses einfache vegane Erdnussbutter-Schoko-Marmor-Bananenbrot Rezept enthält kein Öl und keinen raffiniernten Zucker, ist aber so fluffig, weich, saftig und lecker wie ein Kuchen! Zubereitet mit vielen gesunden Bananen, gelingt es auch glutenfrei und eignet sich perfekt zum Frühstück, als Dessert oder Snack für Zwischendurch!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'JSp3', + 'name': 'Veganes Marmor-Bananenbrot mit Erdnussbutter', + 'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/', + 'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91', + 'recipe_yield': '14 servings', + 'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Es ist kein Geheimnis: Ich mag es gerne schnell und einfach. Und ich liebe Pasta! Deshalb habe ich mich vor ein paar Wochen auf die Suche nach der perfekten, schnellen Tomatensoße gemacht. Es muss da draußen doch irgendein Rezept geben, das (fast) genauso schnell zuzubereiten ist, wie Miracoli und dabei aber das schöne Gefühl hinterlässt, ...', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '9QMh', + 'name': 'Pasta mit Tomaten, Knoblauch und Basilikum - einfach (und) genial! - Kuechenchaotin', + 'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/', + 'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51', + 'recipe_yield': '', + 'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test123', + 'original_url': None, + 'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542', + 'recipe_yield': None, + 'slug': 'test123', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Bureeto', + 'original_url': None, + 'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78', + 'recipe_yield': None, + 'slug': 'bureeto', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Subway Double Cookies', + 'original_url': None, + 'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0', + 'recipe_yield': None, + 'slug': 'subway-double-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'qwerty12345', + 'original_url': None, + 'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4', + 'recipe_yield': None, + 'slug': 'qwerty12345', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'beGq', + 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', + 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', + 'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22', + 'recipe_yield': '24 servings', + 'slug': 'cheeseburger-sliders-easy-30-min-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'meatloaf', + 'original_url': None, + 'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291', + 'recipe_yield': '4', + 'slug': 'meatloaf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Richtig rheinischer Sauerbraten - Rheinischer geht's nicht! Über 536 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kCBh', + 'name': 'Richtig rheinischer Sauerbraten', + 'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html', + 'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117', + 'recipe_yield': '4 servings', + 'slug': 'richtig-rheinischer-sauerbraten', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Orientalischer Gemüse-Hähnchen Eintopf. Über 164 Bewertungen und für köstlich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'kpBx', + 'name': 'Orientalischer Gemüse-Hähnchen Eintopf', + 'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html', + 'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022', + 'recipe_yield': '6 servings', + 'slug': 'orientalischer-gemuse-hahnchen-eintopf', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 20240121', + 'original_url': None, + 'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d', + 'recipe_yield': '4', + 'slug': 'test-20240121', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Zet in 20 minuten deze lekkere loempia bowl in elkaar. Makkelijk, snel en weer eens wat anders. Lekker met prei, sojasaus en kipgehakt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'McEx', + 'name': 'Loempia bowl', + 'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/', + 'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164', + 'recipe_yield': '', + 'slug': 'loempia-bowl', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Chocolate Mousse with Aquafaba, to make the fluffiest of mousses. Whip up this dessert in literally five minutes and chill in the fridge until you're ready to serve!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'bzqo', + 'name': '5 Ingredient Chocolate Mousse', + 'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/', + 'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4', + 'recipe_yield': '6 servings', + 'slug': '5-ingredient-chocolate-mousse', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Der perfekte Pfannkuchen - gelingt einfach immer - von Kindern geliebt und auch für Kochneulinge super geeignet. Über 2529 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KGK6', + 'name': 'Der perfekte Pfannkuchen - gelingt einfach immer', + 'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html', + 'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c', + 'recipe_yield': '4 servings', + 'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Für alle Liebhaber von Dinkel ist dieses Dinkel-Sauerteigbrot ein absolutes Muss. Aussen knusprig und innen herrlich feucht und grossporig.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'yNDq', + 'name': 'Dinkel-Sauerteigbrot', + 'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/', + 'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842', + 'recipe_yield': '1', + 'slug': 'dinkel-sauerteigbrot', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 234234', + 'original_url': None, + 'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37', + 'recipe_yield': None, + 'slug': 'test-234234', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'test 243', + 'original_url': None, + 'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688', + 'recipe_yield': None, + 'slug': 'test-243', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'nOPT', + 'name': 'Einfacher Nudelauflauf mit Brokkoli', + 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', + 'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159', + 'recipe_yield': '4 servings', + 'slug': 'einfacher-nudelauflauf-mit-brokkoli', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': ''' + Tarta cytrynowa z bezą + Lekko kwaśna masa cytrynowa, która równoważy słodycz bezy – jeśli to brzmi jak ciasto, które chętnie zjesz na deser, wypróbuj nasz przepis! Tarta z bezą i masą cytrynową nawiązuje do kuchni francuskiej, znanej z wyśmienitych quiche i tart. Tym razem proponujemy ją w wersji na słodko. + Dla kogo? + Lubisz ciasta o delikatnym, kruchym spodzie? Posmakuje ci tarta cytrynowa z bezą. Przepis jest wprost stworzony dla miłośników lekko cierpkiego smaku cytrusów w wypiekach. Tarta cytrynowa z bezą zdecydowanie nie jest mdłym ciastem! + Na jaką okazję? + Na rodzinnym stole, zamiast zwykłego sernika lub ciasta czekoladowego, może stanąć właśnie tarta cytrynowa z bezą. Przepis ten skradnie serce twojej przyjaciółki lub przyjaciela, którego zaprosisz na herbatę i ciasto. Naszym zdaniem ma też dużą szansę stać się hitem urodzinowej imprezy, gdy pojawi się tuż obok tortu. Tarta cytrynowa z bezą smakuje doskonale w okresie świątecznym – upiecz ją na Wielkanoc oprócz tradycyjnego mazurka i baby. + Czy wiesz, że? + Zastanawiasz się, czy kupione kilka dni temu cytryny możesz przeznaczyć do przepisu na tartę? Jest wiele sposobów na przedłużenie ich świeżości. Niektórzy trzymają je w lodówce, w torebce zamykanej strunowo. Ciekawostka: im mocniej pachnie cytryna, tym kwaśniejsza będzie w smaku. + Dla urozmaicenia: + Martwisz się o to, czy każda warstwa tarty odpowiednio się upiecze? Mamy na to sposób. Piecz ją w piekarniku bez termoobiegu, ustawionym na grzanie góra–dół. + ''', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'vxuL', + 'name': 'Tarta cytrynowa z bezą', + 'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza', + 'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554', + 'recipe_yield': '8 servings', + 'slug': 'tarta-cytrynowa-z-beza', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Martins test Recipe', + 'original_url': None, + 'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c', + 'recipe_yield': None, + 'slug': 'martins-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Muffinki czekoladowe to przepyszny i bardzo prosty w przygotowaniu mini deser pieczony w papilotkach. Przepis na najlepsze, bardzo wilgotne i puszyste muffinki czekoladowe polecam każdemu miłośnikowi czekolady.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xP1Q', + 'name': 'Muffinki czekoladowe', + 'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe', + 'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69', + 'recipe_yield': '12', + 'slug': 'muffinki-czekoladowe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Recipe', + 'original_url': None, + 'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf', + 'recipe_yield': None, + 'slug': 'my-test-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'My Test Receipe', + 'original_url': None, + 'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816', + 'recipe_yield': None, + 'slug': 'my-test-receipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Régalez vous avec ces patates douces cuites au four et légèrement parfumées au thym et au piment. Super bon avec un poulet rôti par exemple.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'r1ck', + 'name': 'Patates douces au four', + 'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/', + 'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f', + 'recipe_yield': '', + 'slug': 'patates-douces-au-four', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Follow these basic instructions for a thick, crisp, and chewy pizza crust at home. The recipe yields enough pizza dough for two 12-inch pizzas and you can freeze half of the dough for later. Close to 2 pounds of dough total.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'gD94', + 'name': 'Easy Homemade Pizza Dough', + 'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/', + 'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec', + 'recipe_yield': '2 servings', + 'slug': 'easy-homemade-pizza-dough', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '356X', + 'name': 'All-American Beef Stew Recipe', + 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', + 'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e', + 'recipe_yield': '6 servings', + 'slug': 'all-american-beef-stew-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This utterly faithful recipe perfectly recreates a New York City halal-cart classic: Chicken and Rice with White Sauce. The chicken is marinated with herbs, lemon, and spices; the rice golden; the sauce, as white and creamy as ever.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '4Sys', + 'name': "Serious Eats' Halal Cart-Style Chicken and Rice With White Sauce", + 'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe', + 'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c', + 'recipe_yield': '4 servings', + 'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Schnelle Käsespätzle. Über 1201 Bewertungen und für sehr gut befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '8goY', + 'name': 'Schnelle Käsespätzle', + 'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html', + 'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e', + 'recipe_yield': '4 servings', + 'slug': 'schnelle-kasespatzle', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'taco', + 'original_url': None, + 'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3', + 'recipe_yield': None, + 'slug': 'taco', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'z8BB', + 'name': 'Vodkapasta', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Har du inte provat denna trendiga pasta är det hög tid! Enkel och gräddig vardagspasta med smak av tomat och chili och en hemlig ingrediens som ger denna rätt extra sting, nämligen vodka.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'Nqpz', + 'name': 'Vodkapasta2', + 'original_url': 'https://www.ica.se/recept/vodkapasta-729011/', + 'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733', + 'recipe_yield': '4 servings', + 'slug': 'vodkapasta2', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Rub', + 'original_url': None, + 'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670', + 'recipe_yield': '1', + 'slug': 'rub', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Tender and moist, these chocolate chip cookies were a HUGE hit in the Test Kitchen. They're like banana bread in a cookie form. Outside, there are crisp edges like a cookie. Inside, though, it's soft like banana bread. We opted to add chocolate chips and nuts. It's a classic flavor combination in banana bread and works just as well in these cookies.", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': '03XS', + 'name': 'Banana Bread Chocolate Chip Cookies', + 'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html', + 'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523', + 'recipe_yield': '', + 'slug': 'banana-bread-chocolate-chip-cookies', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': "Hello friends, today I'm going to share with you how to make a delicious soup/bisque. A Cauliflower Bisques Recipe with Cheddar Cheese. One of my favorite soups to make when its cold outside. We will be continuing the soup collection so let me know what you think in the comments below!", + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'KuXV', + 'name': 'Cauliflower Bisque Recipe with Cheddar Cheese', + 'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/', + 'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041', + 'recipe_yield': '', + 'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'Prova ', + 'original_url': None, + 'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a', + 'recipe_yield': '', + 'slug': 'prova', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre (1)', + 'original_url': None, + 'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4', + 'recipe_yield': None, + 'slug': 'pate-au-beurre-1', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': '', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': None, + 'name': 'pate au beurre', + 'original_url': None, + 'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c', + 'recipe_yield': None, + 'slug': 'pate-au-beurre', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Individual foolproof mason jar cheesecakes with strawberry compote and a Graham cracker crumble topping. Foolproof, simple, and delicious.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'tmwm', + 'name': 'Sous Vide Cheesecake Recipe', + 'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/', + 'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517', + 'recipe_yield': '4 servings', + 'slug': 'sous-vide-cheesecake-recipe', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'This is a variation of the several cheese cake recipes that have been used for sous vide. These make a fabulous 4oz cheese cake for dessert. Garnish with a raspberry or blackberry and impress your family and friends. They’ll keep great in the fridge for a week easily.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'xCYc', + 'name': 'The Bomb Mini Cheesecakes', + 'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes', + 'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b', + 'recipe_yield': '10 servings', + 'slug': 'the-bomb-mini-cheesecakes', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tagliatelle al Salmone - wie beim Italiener. Über 1568 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'qzaN', + 'name': 'Tagliatelle al Salmone', + 'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html', + 'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee', + 'recipe_yield': '4 servings', + 'slug': 'tagliatelle-al-salmone', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Hier ist der Name Programm: Den "Tod durch Schokolade" müsst ihr zwar hoffentlich nicht erleiden, aber Chocoholics werden diesen Kuchen lieben!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'K9qP', + 'name': 'Death by Chocolate', + 'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html', + 'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627', + 'recipe_yield': '1 serving', + 'slug': 'death-by-chocolate', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Palak Dal ist in Grunde genommen Spinat (Palak) mit Linsen oder anderen Hülsenfrüchten (Dal) vom indischen Subkontinent. Es kommen noch Zwiebeln, Tomaten und einige indische Gewürze dazu. Damit ist das Palak Dal ein super einfaches und zugleich veganes indisches Rezept. Es schmeckt hervorragend mit Naan-Brot und etwas gewürztem Joghurt.', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'jKQ3', + 'name': 'Palak Dal Rezept aus Indien', + 'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/', + 'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c', + 'recipe_yield': '4 servings', + 'slug': 'palak-dal-rezept-aus-indien', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + dict({ + 'description': 'Tortelline - á la Romana. Über 13 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!', + 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': 'cd2bb87f-5e4c-4dc6-8477-af9537200014', + 'image': 'rkSn', + 'name': 'Tortelline - á la Romana', + 'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html', + 'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3', + 'recipe_yield': '4 servings', + 'slug': 'tortelline-a-la-romana', + 'user_id': '1ce8b5fe-04e8-4b80-aab1-d92c94685c6d', + }), + ]), + }), + }) +# --- # name: test_service_import_recipe dict({ 'recipe': dict({ diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 57c55159bdc..2ced94a7399 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -21,6 +21,8 @@ from homeassistant.components.mealie.const import ( ATTR_NOTE_TEXT, ATTR_NOTE_TITLE, ATTR_RECIPE_ID, + ATTR_RESULT_LIMIT, + ATTR_SEARCH_TERMS, ATTR_START_DATE, ATTR_URL, DOMAIN, @@ -28,6 +30,7 @@ from homeassistant.components.mealie.const import ( from homeassistant.components.mealie.services import ( SERVICE_GET_MEALPLAN, SERVICE_GET_RECIPE, + SERVICE_GET_RECIPES, SERVICE_IMPORT_RECIPE, SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, @@ -150,6 +153,42 @@ async def test_service_recipe( assert response == snapshot +@pytest.mark.parametrize( + "service_data", + [ + # Default call + {ATTR_CONFIG_ENTRY_ID: "mock_entry_id"}, + # With search terms and result limit + { + ATTR_CONFIG_ENTRY_ID: "mock_entry_id", + ATTR_SEARCH_TERMS: "pasta", + ATTR_RESULT_LIMIT: 5, + }, + ], +) +async def test_service_get_recipes( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service_data: dict, +) -> None: + """Test the get_recipes service.""" + await setup_integration(hass, mock_config_entry) + + # Patch entry_id into service_data for each run + service_data = {**service_data, ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_RECIPES, + service_data, + blocking=True, + return_response=True, + ) + assert response == snapshot + + async def test_service_import_recipe( hass: HomeAssistant, mock_mealie_client: AsyncMock, @@ -332,6 +371,22 @@ async def test_service_set_mealplan( ServiceValidationError, "Recipe with ID or slug `recipe_id` not found", ), + ( + SERVICE_GET_RECIPES, + {}, + "get_recipes", + MealieConnectionError, + HomeAssistantError, + "Error connecting to Mealie instance", + ), + ( + SERVICE_GET_RECIPES, + {ATTR_SEARCH_TERMS: "pasta"}, + "get_recipes", + MealieNotFoundError, + ServiceValidationError, + "No recipes found matching your search", + ), ( SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}, @@ -402,6 +457,11 @@ async def test_services_connection_error( [ (SERVICE_GET_MEALPLAN, {}), (SERVICE_GET_RECIPE, {ATTR_RECIPE_ID: "recipe_id"}), + (SERVICE_GET_RECIPES, {}), + ( + SERVICE_GET_RECIPES, + {ATTR_SEARCH_TERMS: "pasta", ATTR_RESULT_LIMIT: 5}, + ), (SERVICE_IMPORT_RECIPE, {ATTR_URL: "http://example.com"}), ( SERVICE_SET_RANDOM_MEALPLAN, From 99ee56a4ddb350389d101adb7f43267c75609734 Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 30 Jul 2025 07:45:03 -0600 Subject: [PATCH 0768/1118] Add Precipitation sensors to Weatherflow Cloud (#149619) Co-authored-by: Joost Lekkerkerker --- .../components/weatherflow_cloud/icons.json | 55 ++ .../components/weatherflow_cloud/sensor.py | 83 +++ .../components/weatherflow_cloud/strings.json | 28 + .../snapshots/test_sensor.ambr | 516 ++++++++++++++++++ 4 files changed, 682 insertions(+) diff --git a/homeassistant/components/weatherflow_cloud/icons.json b/homeassistant/components/weatherflow_cloud/icons.json index 5b9cd9c6cf4..a5759d8b810 100644 --- a/homeassistant/components/weatherflow_cloud/icons.json +++ b/homeassistant/components/weatherflow_cloud/icons.json @@ -34,6 +34,60 @@ "lightning_strike_last_epoch": { "default": "mdi:lightning-bolt" }, + + "precip_accum_local_day": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_day_final": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_yesterday": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + "precip_accum_local_yesterday_final": { + "default": "mdi:umbrella-closed", + "range": { + "0.01": "mdi:umbrella" + } + }, + + "precip_minutes_local_day": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + "precip_minutes_local_yesterday": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + "precip_minutes_local_yesterday_final": { + "default": "mdi:umbrella-closed", + "range": { + "1": "mdi:umbrella" + } + }, + + "precip_analysis_type_yesterday": { + "default": "mdi:radar", + "state": { + "rain": "mdi:weather-rainy", + "snow": "mdi:weather-snowy", + "rain_snow": "mdi:weather-snoy-rainy", + "lightning": "mdi:weather-lightning-rainy" + } + }, "sea_level_pressure": { "default": "mdi:gauge" }, @@ -49,6 +103,7 @@ "wind_chill": { "default": "mdi:snowflake-thermometer" }, + "wind_direction": { "default": "mdi:compass", "range": { diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index 42357807d17..ec094448519 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -39,6 +39,14 @@ from .const import DOMAIN from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator from .entity import WeatherFlowCloudEntity +PRECIPITATION_TYPE = { + 0: "none", + 1: "rain", + 2: "snow", + 3: "sleet", + 4: "storm", +} + @dataclass(frozen=True, kw_only=True) class WeatherFlowCloudSensorEntityDescription( @@ -223,6 +231,81 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3, ), + # Rain Sensors + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_last_1hr", + translation_key="precip_accum_last_1hr", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_last_1hr, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_day", + translation_key="precip_accum_local_day", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_day, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_day_final", + translation_key="precip_accum_local_day_final", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_day_final, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_yesterday", + translation_key="precip_accum_local_yesterday", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_yesterday, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_accum_local_yesterday_final", + translation_key="precip_accum_local_yesterday_final", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: data.precip_accum_local_yesterday_final, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_analysis_type_yesterday", + translation_key="precip_analysis_type_yesterday", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "snow", "sleet", "storm"], + suggested_display_precision=1, + value_fn=lambda data: PRECIPITATION_TYPE.get( + data.precip_analysis_type_yesterday + ), + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_day", + translation_key="precip_minutes_local_day", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_day, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_yesterday", + translation_key="precip_minutes_local_yesterday", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_yesterday, + ), + WeatherFlowCloudSensorEntityDescription( + key="precip_minutes_local_yesterday_final", + translation_key="precip_minutes_local_yesterday_final", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=1, + value_fn=lambda data: data.precip_minutes_local_yesterday_final, + ), # Lightning Sensors WeatherFlowCloudSensorEntityDescription( key="lightning_strike_count", diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index 6c6e6f122a4..5b628e9f5c8 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -56,6 +56,34 @@ "lightning_strike_last_epoch": { "name": "Lightning last strike" }, + "precip_accum_last_1hr": { + "name": "Rain last hour" + }, + + "precip_accum_local_day": { + "name": "Precipitation today" + }, + "precip_accum_local_day_final": { + "name": "Nearcast precipitation today" + }, + "precip_accum_local_yesterday": { + "name": "Precipitation yesterday" + }, + "precip_accum_local_yesterday_final": { + "name": "Nearcast precipitation yesterday" + }, + "precip_analysis_type_yesterday": { + "name": "Precipitation type yesterday" + }, + "precip_minutes_local_day": { + "name": "Precipitation duration today" + }, + "precip_minutes_local_yesterday": { + "name": "Precipitation duration yesterday" + }, + "precip_minutes_local_yesterday_final": { + "name": "Nearcast precipitation duration yesterday" + }, "sea_level_pressure": { "name": "Pressure sea level" }, diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index a34d885b77b..cd6280077a2 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -489,6 +489,466 @@ 'state': '2024-02-07T23:01:15+00:00', }) # --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_duration_yesterday-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.my_home_station_nearcast_precipitation_duration_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation duration yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_yesterday_final', + 'unique_id': '24432_precip_minutes_local_yesterday_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_duration_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation duration yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_duration_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_today-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.my_home_station_nearcast_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_day_final', + 'unique_id': '24432_precip_accum_local_day_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_yesterday-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.my_home_station_nearcast_precipitation_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nearcast precipitation yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_yesterday_final', + 'unique_id': '24432_precip_accum_local_yesterday_final', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_nearcast_precipitation_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Nearcast precipitation yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_nearcast_precipitation_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_today-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.my_home_station_precipitation_duration_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation duration today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_day', + 'unique_id': '24432_precip_minutes_local_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation duration today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_duration_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_yesterday-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.my_home_station_precipitation_duration_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation duration yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_minutes_local_yesterday', + 'unique_id': '24432_precip_minutes_local_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_duration_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation duration yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_duration_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_today-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.my_home_station_precipitation_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation today', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_day', + 'unique_id': '24432_precip_accum_local_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_type_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'rain', + 'snow', + 'sleet', + 'storm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_home_station_precipitation_type_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation type yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_analysis_type_yesterday', + 'unique_id': '24432_precip_analysis_type_yesterday', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_type_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'enum', + 'friendly_name': 'My Home Station Precipitation type yesterday', + 'options': list([ + 'none', + 'rain', + 'snow', + 'sleet', + 'storm', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_type_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_yesterday-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.my_home_station_precipitation_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Precipitation yesterday', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_local_yesterday', + 'unique_id': '24432_precip_accum_local_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_precipitation_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Precipitation yesterday', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_precipitation_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.my_home_station_pressure_barometric-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -609,6 +1069,62 @@ 'state': '1006.2', }) # --- +# name: test_all_entities[sensor.my_home_station_rain_last_hour-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.my_home_station_rain_last_hour', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rain last hour', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precip_accum_last_1hr', + 'unique_id': '24432_precip_accum_last_1hr', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_rain_last_hour-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Rain last hour', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_rain_last_hour', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.my_home_station_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 223c34056d28bba0fd6250c7c1ea081e9a6dcb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 30 Jul 2025 15:58:43 +0200 Subject: [PATCH 0769/1118] Add missing colons in miele messages (#149668) --- homeassistant/components/miele/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index cec4a63feec..01f13c8550d 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1063,13 +1063,13 @@ "message": "Invalid device targeted." }, "get_programs_error": { - "message": "'Get programs' action failed {status} / {message}." + "message": "'Get programs' action failed: {status} / {message}" }, "set_program_error": { - "message": "'Set program' action failed {status} / {message}." + "message": "'Set program' action failed: {status} / {message}" }, "set_program_oven_error": { - "message": "'Set program on oven' action failed {status} / {message}." + "message": "'Set program on oven' action failed: {status} / {message}" }, "set_state_error": { "message": "Failed to set state for {entity}." From 1b58809655bebe1f50159efc5670f3a5459696c6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Jul 2025 16:01:44 +0200 Subject: [PATCH 0770/1118] Add AI Task to OpenRouter (#149275) --- .../components/open_router/__init__.py | 2 +- .../components/open_router/ai_task.py | 75 +++++++ .../components/open_router/config_flow.py | 82 ++++++- .../components/open_router/conversation.py | 2 + .../components/open_router/entity.py | 96 ++++++-- .../components/open_router/strings.json | 23 +- tests/components/open_router/conftest.py | 21 +- .../open_router/fixtures/models.json | 1 + .../open_router/snapshots/test_ai_task.ambr | 53 +++++ tests/components/open_router/test_ai_task.py | 210 ++++++++++++++++++ .../open_router/test_config_flow.py | 66 +++++- .../open_router/test_conversation.py | 9 +- 12 files changed, 601 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/open_router/ai_task.py create mode 100644 tests/components/open_router/snapshots/test_ai_task.ambr create mode 100644 tests/components/open_router/test_ai_task.py diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index 477fabca54c..9850f72f71d 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.httpx_client import get_async_client from .const import LOGGER -PLATFORMS = [Platform.CONVERSATION] +PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION] type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI] diff --git a/homeassistant/components/open_router/ai_task.py b/homeassistant/components/open_router/ai_task.py new file mode 100644 index 00000000000..fa5d8d0f68e --- /dev/null +++ b/homeassistant/components/open_router/ai_task.py @@ -0,0 +1,75 @@ +"""AI Task integration for OpenRouter.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from . import OpenRouterConfigEntry +from .entity import OpenRouterEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenRouterAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenRouterAITaskEntity( + ai_task.AITaskEntity, + OpenRouterEntity, +): + """OpenRouter AI Task entity.""" + + _attr_name = None + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + raise HomeAssistantError( + "Error with OpenRouter structured response" + ) from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index 96f3769575b..2afe2129a4c 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -5,7 +5,12 @@ from __future__ import annotations import logging from typing import Any -from python_open_router import Model, OpenRouterClient, OpenRouterError +from python_open_router import ( + Model, + OpenRouterClient, + OpenRouterError, + SupportedParameter, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -43,7 +48,10 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this handler.""" - return {"conversation": ConversationFlowHandler} + return { + "conversation": ConversationFlowHandler, + "ai_task_data": AITaskDataFlowHandler, + } async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -78,13 +86,26 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): ) -class ConversationFlowHandler(ConfigSubentryFlow): - """Handle subentry flow.""" +class OpenRouterSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for OpenRouter.""" def __init__(self) -> None: """Initialize the subentry flow.""" self.models: dict[str, Model] = {} + async def _get_models(self) -> None: + """Fetch models from OpenRouter.""" + entry = self._get_entry() + client = OpenRouterClient( + entry.data[CONF_API_KEY], async_get_clientsession(self.hass) + ) + models = await client.get_models() + self.models = {model.id: model for model in models} + + +class ConversationFlowHandler(OpenRouterSubentryFlowHandler): + """Handle subentry flow.""" + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -95,14 +116,16 @@ class ConversationFlowHandler(ConfigSubentryFlow): return self.async_create_entry( title=self.models[user_input[CONF_MODEL]].name, data=user_input ) - entry = self._get_entry() - client = OpenRouterClient( - entry.data[CONF_API_KEY], async_get_clientsession(self.hass) - ) - models = await client.get_models() - self.models = {model.id: model for model in models} + try: + await self._get_models() + except OpenRouterError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") options = [ - SelectOptionDict(value=model.id, label=model.name) for model in models + SelectOptionDict(value=model.id, label=model.name) + for model in self.models.values() ] hass_apis: list[SelectOptionDict] = [ @@ -138,3 +161,40 @@ class ConversationFlowHandler(ConfigSubentryFlow): } ), ) + + +class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler): + """Handle subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + return self.async_create_entry( + title=self.models[user_input[CONF_MODEL]].name, data=user_input + ) + try: + await self._get_models() + except OpenRouterError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + options = [ + SelectOptionDict(value=model.id, label=model.name) + for model in self.models.values() + if SupportedParameter.STRUCTURED_OUTPUTS in model.supported_parameters + ] + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + } + ), + ) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 826931d3da7..3c185ecd77c 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -20,6 +20,8 @@ async def async_setup_entry( ) -> None: """Set up conversation entities.""" for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type != "conversation": + continue async_add_entities( [OpenRouterConversationEntity(config_entry, subentry)], config_subentry_id=subentry_id, diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index e706656d377..ac01ec89704 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -4,10 +4,9 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal import openai -from openai import NOT_GIVEN from openai.types.chat import ( ChatCompletionAssistantMessageParam, ChatCompletionMessage, @@ -19,7 +18,9 @@ from openai.types.chat import ( ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition +from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema +from openai.types.shared_params.response_format_json_schema import JSONSchema +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -36,6 +37,50 @@ from .const import DOMAIN, LOGGER MAX_TOOL_ITERATIONS = 10 +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenRouter API.""" + if schema["type"] == "object": + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + name: str, schema: vol.Schema, llm_api: llm.APIInstance | None +) -> JSONSchema: + """Format the schema to be compatible with OpenRouter API.""" + result: JSONSchema = { + "name": name, + "strict": True, + } + result_schema = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result_schema) + + result["schema"] = result_schema + return result + + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None, @@ -136,9 +181,24 @@ class OpenRouterEntity(Entity): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log(self, chat_log: conversation.ChatLog) -> None: + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: """Generate an answer for the chat log.""" + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + "extra_headers": { + "X-Title": "Home Assistant", + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + }, + "extra_body": {"require_parameters": True}, + } + tools: list[ChatCompletionToolParam] | None = None if chat_log.llm_api: tools = [ @@ -146,33 +206,37 @@ class OpenRouterEntity(Entity): for tool in chat_log.llm_api.tools ] - messages = [ + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ m for content in chat_log.content if (m := _convert_content_to_chat_message(content)) ] + if structure: + if TYPE_CHECKING: + assert structure_name is not None + model_args["response_format"] = ResponseFormatJSONSchema( + type="json_schema", + json_schema=_format_structured_output( + structure_name, structure, chat_log.llm_api + ), + ) + client = self.entry.runtime_data for _iteration in range(MAX_TOOL_ITERATIONS): try: - result = await client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools or NOT_GIVEN, - user=chat_log.conversation_id, - extra_headers={ - "X-Title": "Home Assistant", - "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", - }, - ) + result = await client.chat.completions.create(**model_args) except openai.OpenAIError as err: LOGGER.error("Error talking to API: %s", err) raise HomeAssistantError("Error talking to API") from err result_message = result.choices[0].message - messages.extend( + model_args["messages"].extend( [ msg async for content in chat_log.async_add_delta_content_stream( diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index 91c4cc350ae..e73a65cd178 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -37,7 +37,28 @@ "initiate_flow": { "user": "Add conversation agent" }, - "entry_type": "Conversation agent" + "entry_type": "Conversation agent", + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "ai_task_data": { + "step": { + "user": { + "data": { + "model": "[%key:component::open_router::config_subentries::conversation::step::user::data::model%]" + } + } + }, + "initiate_flow": { + "user": "Add Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } } } } diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index 7bb967f369f..33ca4d790c9 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -49,9 +49,19 @@ def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: return res +@pytest.fixture +def ai_task_data_subentry_data() -> dict[str, Any]: + """Mock AI task subentry data.""" + return { + CONF_MODEL: "google/gemini-1.5-pro", + } + + @pytest.fixture def mock_config_entry( - hass: HomeAssistant, conversation_subentry_data: dict[str, Any] + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], + ai_task_data_subentry_data: dict[str, Any], ) -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( @@ -67,7 +77,14 @@ def mock_config_entry( subentry_type="conversation", title="GPT-3.5 Turbo", unique_id=None, - ) + ), + ConfigSubentryData( + data=ai_task_data_subentry_data, + subentry_id="ABCDEG", + subentry_type="ai_task_data", + title="Gemini 1.5 Pro", + unique_id=None, + ), ], ) diff --git a/tests/components/open_router/fixtures/models.json b/tests/components/open_router/fixtures/models.json index 0a35686094e..b17f584c0e6 100644 --- a/tests/components/open_router/fixtures/models.json +++ b/tests/components/open_router/fixtures/models.json @@ -85,6 +85,7 @@ "logit_bias", "logprobs", "top_logprobs", + "structured_outputs", "response_format" ] } diff --git a/tests/components/open_router/snapshots/test_ai_task.ambr b/tests/components/open_router/snapshots/test_ai_task.ambr new file mode 100644 index 00000000000..0839f6fef9b --- /dev/null +++ b/tests/components/open_router/snapshots/test_ai_task.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_all_entities[ai_task.gemini_1_5_pro-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'ai_task', + 'entity_category': None, + 'entity_id': 'ai_task.gemini_1_5_pro', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEG', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ai_task.gemini_1_5_pro-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gemini 1.5 Pro', + 'supported_features': , + }), + 'context': , + 'entity_id': 'ai_task.gemini_1_5_pro', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/open_router/test_ai_task.py b/tests/components/open_router/test_ai_task.py new file mode 100644 index 00000000000..0b6c2933be7 --- /dev/null +++ b/tests/components/open_router/test_ai_task.py @@ -0,0 +1,210 @@ +"""Test AI Task structured data generation.""" + +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.open_router.PLATFORMS", + [Platform.AI_TASK], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task data generation.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "ai_task.gemini_1_5_pro" + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="The test data", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task structured data generation.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content='{"characters": ["Mario", "Luigi"]}', + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.gemini_1_5_pro", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + assert mock_openai_client.chat.completions.create.call_args_list[0][1][ + "response_format" + ] == { + "json_schema": { + "name": "Test Task", + "schema": { + "properties": { + "characters": { + "items": {"type": "string"}, + "type": "array", + } + }, + "required": ["characters"], + "type": "object", + }, + "strict": True, + }, + "type": "json_schema", + } + + +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, +) -> None: + """Test AI Task with invalid JSON response.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="INVALID JSON RESPONSE", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="x-ai/grok-3", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + + with pytest.raises( + HomeAssistantError, match="Error with OpenRouter structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.gemini_1_5_pro", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 0720f6d90f5..b406e75507b 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -110,9 +110,6 @@ async def test_create_conversation_agent( mock_config_entry: MockConfigEntry, ) -> None: """Test creating a conversation agent.""" - - mock_config_entry.add_to_hass(hass) - await setup_integration(hass, mock_config_entry) result = await hass.config_entries.subentries.async_init( @@ -152,9 +149,6 @@ async def test_create_conversation_agent_no_control( mock_config_entry: MockConfigEntry, ) -> None: """Test creating a conversation agent without control over the LLM API.""" - - mock_config_entry.add_to_hass(hass) - await setup_integration(hass, mock_config_entry) result = await hass.config_entries.subentries.async_init( @@ -184,3 +178,63 @@ async def test_create_conversation_agent_no_control( CONF_MODEL: "openai/gpt-3.5-turbo", CONF_PROMPT: "you are an assistant", } + + +async def test_create_ai_task( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI Task.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "openai/gpt-4", "label": "OpenAI: GPT-4"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_MODEL: "openai/gpt-4"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_MODEL: "openai/gpt-4"} + + +@pytest.mark.parametrize( + "subentry_type", + ["conversation", "ai_task_data"], +) +@pytest.mark.parametrize( + ("exception", "reason"), + [(OpenRouterError("exception"), "cannot_connect"), (Exception, "unknown")], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + subentry_type: str, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow exceptions.""" + await setup_integration(hass, mock_config_entry) + + mock_open_router_client.get_models.side_effect = exception + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, subentry_type), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 93f8264801a..afbdd907f93 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -1,6 +1,6 @@ """Tests for the OpenRouter integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun import freeze_time from openai.types import CompletionUsage @@ -15,6 +15,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation +from homeassistant.const import Platform from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry as er, intent @@ -40,7 +41,11 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.open_router.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From fc900a632aac01c1e4e5ed6705de7c99b91c2cdc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:04:45 +0200 Subject: [PATCH 0771/1118] Revert logging for unsupported Tuya devices (#149665) --- homeassistant/components/tuya/__init__.py | 11 ----------- tests/components/tuya/test_init.py | 9 --------- 2 files changed, 20 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 6c3aa146158..106075e9314 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,17 +153,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): - if not device.status and not device.status_range and not device.function: - # If the device has no status, status_range or function, - # it cannot be supported - LOGGER.info( - "Device %s (%s) has been ignored as it does not provide any" - " standard instructions (status, status_range and function are" - " all empty) - see %s", - device.product_name, - device.id, - "https://github.com/tuya/tuya-device-sharing-sdk/issues/11", - ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index 8fbf6fb4e3b..9e9855f9fac 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -24,7 +24,6 @@ async def test_unsupported_device( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, - caplog: pytest.LogCaptureFixture, ) -> None: """Test unsupported device.""" @@ -39,11 +38,3 @@ async def test_unsupported_device( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - - # Information log entry added - assert ( - "Device DOLCECLIMA 10 HP WIFI (mock_device_id) has been ignored" - " as it does not provide any standard instructions (status, status_range" - " and function are all empty) - see " - "https://github.com/tuya/tuya-device-sharing-sdk/issues/11" in caplog.text - ) From 160b61e0b9c57a25def6153058996c342e752a3e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:17:49 -0400 Subject: [PATCH 0772/1118] Add config flow to template fan platform (#149446) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/template/config_flow.py | 33 ++++++++++ homeassistant/components/template/fan.py | 46 +++++++++++++- .../components/template/strings.json | 60 ++++++++++++++++++ .../template/snapshots/test_fan.ambr | 15 +++++ tests/components/template/test_config_flow.py | 32 ++++++++++ tests/components/template/test_fan.py | 61 ++++++++++++++++++- 6 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 tests/components/template/snapshots/test_fan.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index c9028d058bf..8653a2f4646 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -72,6 +72,14 @@ from .cover import ( STOP_ACTION, async_create_preview_cover, ) +from .fan import ( + CONF_OFF_ACTION, + CONF_ON_ACTION, + CONF_PERCENTAGE, + CONF_SET_PERCENTAGE_ACTION, + CONF_SPEED_COUNT, + async_create_preview_fan, +) from .light import ( CONF_HS, CONF_HS_ACTION, @@ -182,6 +190,19 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: ) } + if domain == Platform.FAN: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_ON_ACTION): selector.ActionSelector(), + vol.Required(CONF_OFF_ACTION): selector.ActionSelector(), + vol.Optional(CONF_PERCENTAGE): selector.TemplateSelector(), + vol.Optional(CONF_SET_PERCENTAGE_ACTION): selector.ActionSelector(), + vol.Optional(CONF_SPEED_COUNT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=100, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + } + if domain == Platform.IMAGE: schema |= { vol.Required(CONF_URL): selector.TemplateSelector(), @@ -379,6 +400,7 @@ TEMPLATE_TYPES = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.FAN, Platform.IMAGE, Platform.LIGHT, Platform.NUMBER, @@ -408,6 +430,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.FAN: SchemaFlowFormStep( + config_schema(Platform.FAN), + preview="template", + validate_user_input=validate_user_input(Platform.FAN), + ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), preview="template", @@ -462,6 +489,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.COVER), ), + Platform.FAN: SchemaFlowFormStep( + options_schema(Platform.FAN), + preview="template", + validate_user_input=validate_user_input(Platform.FAN), + ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), preview="template", @@ -501,6 +533,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, Platform.BINARY_SENSOR: async_create_preview_binary_sensor, Platform.COVER: async_create_preview_cover, + Platform.FAN: async_create_preview_fan, Platform.LIGHT: async_create_preview_light, Platform.NUMBER: async_create_preview_number, Platform.SELECT: async_create_preview_select, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 381d58a8a9c..9504ba45ab9 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -20,6 +20,7 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -34,15 +35,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, @@ -132,6 +141,10 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} ) +FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -153,6 +166,35 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateFanEntity, + FAN_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_fan( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateFanEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateFanEntity, + FAN_CONFIG_ENTRY_SCHEMA, + ) + + class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 070dd75865f..f1c754a1e61 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -111,6 +111,36 @@ }, "title": "Template cover" }, + "fan": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "percentage": "Percentage", + "set_percentage": "Actions on set percentage", + "speed_count": "Speed count" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the fan. Valid values: `on`, `off`.", + "turn_off": "Defines actions to run when the fan is turned off.", + "turn_on": "Defines actions to run when the fan is turned on.", + "percentage": "Defines a template to get the speed percentage of the fan.", + "set_percentage": "Defines actions to run when the fan is given a speed percentage command.", + "speed_count": "The number of speeds the fan supports. Used to calculate the percentage step for the `fan.increase_speed` and `fan.decrease_speed` actions." + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template fan" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -232,6 +262,7 @@ "binary_sensor": "Template a binary sensor", "button": "Template a button", "cover": "Template a cover", + "fan": "Template a fan", "image": "Template an image", "light": "Template a light", "number": "Template a number", @@ -360,6 +391,35 @@ }, "title": "[%key:component::template::config::step::cover::title%]" }, + "fan": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "turn_off": "[%key:component::template::common::turn_off%]", + "turn_on": "[%key:component::template::common::turn_on%]", + "percentage": "[%key:component::template::config::step::fan::data::percentage%]", + "set_percentage": "[%key:component::template::config::step::fan::data::set_percentage%]", + "speed_count": "[%key:component::template::config::step::fan::data::speed_count%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::fan::data_description::state%]", + "turn_off": "[%key:component::template::config::step::fan::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]", + "percentage": "[%key:component::template::config::step::fan::data_description::percentage%]", + "set_percentage": "[%key:component::template::config::step::fan::data_description::set_percentage%]", + "speed_count": "[%key:component::template::config::step::fan::data_description::speed_count%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "[%key:component::template::config::step::fan::title%]" + }, "image": { "data": { "device_id": "[%key:common::config_flow::data::device%]", diff --git a/tests/components/template/snapshots/test_fan.ambr b/tests/components/template/snapshots/test_fan.ambr new file mode 100644 index 00000000000..3026176ef97 --- /dev/null +++ b/tests/components/template/snapshots/test_fan.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index ad992eec79d..68d78ab7a27 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -149,6 +149,16 @@ BINARY_SENSOR_OPTIONS = { }, {}, ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + "on", + {"one": "on", "two": "off"}, + {}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + {}, + ), ( "image", {"url": "{{ states('sensor.one') }}"}, @@ -332,6 +342,12 @@ async def test_config_flow( {"set_cover_position": []}, {"set_cover_position": []}, ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "image", { @@ -534,6 +550,16 @@ async def test_config_flow_device( {"set_cover_position": []}, "state", ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"state": "{{ states('fan.two') }}"}, + ["on", "off"], + {"one": "on", "two": "off"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + "state", + ), ( "image", { @@ -1391,6 +1417,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {"set_cover_position": []}, {"set_cover_position": []}, ), + ( + "fan", + {"state": "{{ states('fan.one') }}"}, + {"turn_on": [], "turn_off": []}, + {"turn_on": [], "turn_off": []}, + ), ( "image", { diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index c0af18166df..b9161edf61a 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import fan, template @@ -21,10 +22,11 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component from tests.components.fan import common +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_fan" TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" @@ -1881,3 +1883,58 @@ async def test_optimistic_option(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a fan from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "on", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "turn_on": [], + "turn_off": [], + "template_type": fan.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("fan.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + fan.DOMAIN, + { + "name": "My template", + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ) + + assert state["state"] == STATE_ON From daea76c2f1e45705bc3a6992240917501b0751ff Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 30 Jul 2025 16:51:10 +0200 Subject: [PATCH 0773/1118] Update frontend to 20250730.0 (#149672) --- 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 791acf8a39c..09461a3543a 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==20250702.3"] + "requirements": ["home-assistant-frontend==20250730.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 24c107e5611..819bb2f5c9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.110.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.3 +home-assistant-frontend==20250730.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8c68449f7d4..f731ecc0e0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250702.3 +home-assistant-frontend==20250730.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f2cf2c491e..64931e1ef4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250702.3 +home-assistant-frontend==20250730.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From edca3fc0b714669d95d96e76992c887ed6ed892d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 30 Jul 2025 16:52:20 +0200 Subject: [PATCH 0774/1118] Add matter to Third Reality (#149659) --- homeassistant/brands/third_reality.json | 2 +- homeassistant/generated/integrations.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/brands/third_reality.json b/homeassistant/brands/third_reality.json index 172b74c42fc..7a4304dad9f 100644 --- a/homeassistant/brands/third_reality.json +++ b/homeassistant/brands/third_reality.json @@ -1,5 +1,5 @@ { "domain": "third_reality", "name": "Third Reality", - "iot_standards": ["zigbee"] + "iot_standards": ["matter", "zigbee"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1eb37ae87d2..c606d79f2c5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6728,6 +6728,7 @@ "third_reality": { "name": "Third Reality", "iot_standards": [ + "matter", "zigbee" ] }, From d481a694f11e679f2f5fc78379919dec5c2d23eb Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:04:08 -0400 Subject: [PATCH 0775/1118] Add config flow to template vacuum platform (#149458) --- .../components/template/config_flow.py | 44 ++++++++++++++ .../components/template/strings.json | 59 ++++++++++++++++++- homeassistant/components/template/vacuum.py | 46 ++++++++++++++- .../template/snapshots/test_vacuum.ambr | 15 +++++ tests/components/template/test_config_flow.py | 32 ++++++++++ tests/components/template/test_vacuum.py | 59 ++++++++++++++++++- 6 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 tests/components/template/snapshots/test_vacuum.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 8653a2f4646..394af688152 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -103,6 +103,18 @@ from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_selec from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity +from .vacuum import ( + CONF_FAN_SPEED, + CONF_FAN_SPEED_LIST, + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, + async_create_preview_vacuum, +) _SCHEMA_STATE: dict[vol.Marker, Any] = { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -294,6 +306,26 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), } + if domain == Platform.VACUUM: + schema |= _SCHEMA_STATE | { + vol.Required(SERVICE_START): selector.ActionSelector(), + vol.Optional(CONF_FAN_SPEED): selector.TemplateSelector(), + vol.Optional(CONF_FAN_SPEED_LIST): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(SERVICE_SET_FAN_SPEED): selector.ActionSelector(), + vol.Optional(SERVICE_STOP): selector.ActionSelector(), + vol.Optional(SERVICE_PAUSE): selector.ActionSelector(), + vol.Optional(SERVICE_RETURN_TO_BASE): selector.ActionSelector(), + vol.Optional(SERVICE_CLEAN_SPOT): selector.ActionSelector(), + vol.Optional(SERVICE_LOCATE): selector.ActionSelector(), + } + schema |= { vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), vol.Optional(CONF_ADVANCED_OPTIONS): section( @@ -407,6 +439,7 @@ TEMPLATE_TYPES = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.VACUUM, ] CONFIG_FLOW = { @@ -465,6 +498,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.VACUUM: SchemaFlowFormStep( + config_schema(Platform.VACUUM), + preview="template", + validate_user_input=validate_user_input(Platform.VACUUM), + ), } @@ -524,6 +562,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.SWITCH), ), + Platform.VACUUM: SchemaFlowFormStep( + options_schema(Platform.VACUUM), + preview="template", + validate_user_input=validate_user_input(Platform.VACUUM), + ), } CREATE_PREVIEW_ENTITY: dict[ @@ -539,6 +582,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, Platform.SWITCH: async_create_preview_switch, + Platform.VACUUM: async_create_preview_vacuum, } diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index f1c754a1e61..cb1e26fac78 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -268,7 +268,8 @@ "number": "Template a number", "select": "Template a select", "sensor": "Template a sensor", - "switch": "Template a switch" + "switch": "Template a switch", + "vacuum": "Template a vacuum" }, "title": "Template helper" }, @@ -293,6 +294,34 @@ } }, "title": "Template switch" + }, + "vacuum": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "start": "Actions on turn off", + "fan_speed": "Fan speed", + "fan_speeds": "Fan speeds", + "set_fan_speed": "Actions on set fan speed", + "stop": "Actions on stop", + "pause": "Actions on pause", + "return_to_base": "Actions on return to base", + "clean_spot": "Actions on clean spot", + "locate": "Actions on locate" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template vacuum" } } }, @@ -552,6 +581,34 @@ } }, "title": "[%key:component::template::config::step::switch::title%]" + }, + "vacuum": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "start": "[%key:component::template::config::step::vacuum::data::start%]", + "fan_speed": "[%key:component::template::config::step::vacuum::data::fan_speed%]", + "fan_speeds": "[%key:component::template::config::step::vacuum::data::fan_speeds%]", + "set_fan_speed": "[%key:component::template::config::step::vacuum::data::set_fan_speed%]", + "stop": "[%key:component::template::config::step::vacuum::data::stop%]", + "pause": "[%key:component::template::config::step::vacuum::data::pause%]", + "return_to_base": "[%key:component::template::config::step::vacuum::data::return_to_base%]", + "clean_spot": "[%key:component::template::config::step::vacuum::data::clean_spot%]", + "locate": "[%key:component::template::config::step::vacuum::data::locate%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template vacuum" } } }, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 67f0f780388..1abfdbd00da 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -22,6 +22,7 @@ from homeassistant.components.vacuum import ( VacuumActivity, VacuumEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -34,16 +35,24 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, @@ -125,6 +134,10 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} ) +VACUUM_CONFIG_ENTRY_SCHEMA = VACUUM_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -146,6 +159,35 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateStateVacuumEntity, + VACUUM_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_vacuum( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateStateVacuumEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + TemplateStateVacuumEntity, + VACUUM_CONFIG_ENTRY_SCHEMA, + ) + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" diff --git a/tests/components/template/snapshots/test_vacuum.ambr b/tests/components/template/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..01cc9c8ba82 --- /dev/null +++ b/tests/components/template/snapshots/test_vacuum.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 68d78ab7a27..9bfb0d439f7 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -229,6 +229,16 @@ BINARY_SENSOR_OPTIONS = { {}, {}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + "docked", + {"one": "docked", "two": "cleaning"}, + {}, + {"start": []}, + {"start": []}, + {}, + ), ], ) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") @@ -398,6 +408,12 @@ async def test_config_flow( {"options": "{{ ['off', 'on', 'auto'] }}"}, {"options": "{{ ['off', 'on', 'auto'] }}"}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"start": []}, + {"start": []}, + ), ], ) async def test_config_flow_device( @@ -647,6 +663,16 @@ async def test_config_flow_device( {}, "value_template", ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"state": "{{ states('vacuum.two') }}"}, + ["docked", "cleaning"], + {"one": "docked", "two": "cleaning"}, + {"start": []}, + {"start": []}, + "state", + ), ], ) @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") @@ -1480,6 +1506,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {}, {}, ), + ( + "vacuum", + {"state": "{{ states('vacuum.one') }}"}, + {"start": []}, + {"start": []}, + ), ], ) async def test_options_flow_change_device( diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 540b4eccd3b..6c7222645b6 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum from homeassistant.components.vacuum import ( @@ -18,10 +19,11 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component from tests.components.vacuum import common +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_vacuum" TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" @@ -1261,3 +1263,56 @@ async def test_optimistic_option( state = hass.states.get(TEST_ENTITY_ID) assert state.state == VacuumActivity.DOCKED + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a vacuum from a config entry.""" + + hass.states.async_set( + "sensor.test_sensor", + "docked", + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_sensor') }}", + "start": [], + "template_type": vacuum.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("vacuum.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + vacuum.DOMAIN, + { + "name": "My template", + "state": "{{ 'cleaning' }}", + "start": [], + }, + ) + + assert state["state"] == VacuumActivity.CLEANING From 6306baa3c985e7a6c47304e11a5291c0ecde460a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:04:39 -0400 Subject: [PATCH 0776/1118] Add config flow to template lock platform (#149449) --- .../components/template/config_flow.py | 21 +++++++ homeassistant/components/template/lock.py | 46 +++++++++++++- .../components/template/strings.json | 46 ++++++++++++++ .../template/snapshots/test_lock.ambr | 15 +++++ tests/components/template/test_config_flow.py | 32 ++++++++++ tests/components/template/test_lock.py | 61 ++++++++++++++++++- 6 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 tests/components/template/snapshots/test_lock.ambr diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 394af688152..2e581628da2 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -89,6 +89,7 @@ from .light import ( CONF_TEMPERATURE_ACTION, async_create_preview_light, ) +from .lock import CONF_LOCK, CONF_OPEN, CONF_UNLOCK, async_create_preview_lock from .number import ( CONF_MAX, CONF_MIN, @@ -233,6 +234,14 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_TEMPERATURE_ACTION): selector.ActionSelector(), } + if domain == Platform.LOCK: + schema |= _SCHEMA_STATE | { + vol.Required(CONF_LOCK): selector.ActionSelector(), + vol.Required(CONF_UNLOCK): selector.ActionSelector(), + vol.Optional(CONF_CODE_FORMAT): selector.TemplateSelector(), + vol.Optional(CONF_OPEN): selector.ActionSelector(), + } + if domain == Platform.NUMBER: schema |= { vol.Required(CONF_STATE): selector.TemplateSelector(), @@ -435,6 +444,7 @@ TEMPLATE_TYPES = [ Platform.FAN, Platform.IMAGE, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -478,6 +488,11 @@ CONFIG_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.LIGHT), ), + Platform.LOCK: SchemaFlowFormStep( + config_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( config_schema(Platform.NUMBER), preview="template", @@ -542,6 +557,11 @@ OPTIONS_FLOW = { preview="template", validate_user_input=validate_user_input(Platform.LIGHT), ), + Platform.LOCK: SchemaFlowFormStep( + options_schema(Platform.LOCK), + preview="template", + validate_user_input=validate_user_input(Platform.LOCK), + ), Platform.NUMBER: SchemaFlowFormStep( options_schema(Platform.NUMBER), preview="template", @@ -578,6 +598,7 @@ CREATE_PREVIEW_ENTITY: dict[ Platform.COVER: async_create_preview_cover, Platform.FAN: async_create_preview_fan, Platform.LIGHT: async_create_preview_light, + Platform.LOCK: async_create_preview_lock, Platform.NUMBER: async_create_preview_number, Platform.SELECT: async_create_preview_select, Platform.SENSOR: async_create_preview_sensor, diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index e89f95734d1..04d26521ef1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -15,6 +15,7 @@ from homeassistant.components.lock import ( LockEntityFeature, LockState, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, CONF_NAME, @@ -26,15 +27,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, @@ -82,6 +91,10 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( } ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) +LOCK_CONFIG_ENTRY_SCHEMA = LOCK_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -102,6 +115,35 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize config entry.""" + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, + ) + + +@callback +def async_create_preview_lock( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateLockEntity: + """Create a preview.""" + return async_setup_template_preview( + hass, + name, + config, + StateLockEntity, + LOCK_CONFIG_ENTRY_SCHEMA, + ) + + class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index cb1e26fac78..edf4516e8ab 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -188,6 +188,29 @@ }, "title": "Template light" }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::common::state%]", + "lock": "Actions on lock", + "unlock": "Actions on unlock", + "code_format": "[%key:component::template::common::code_format%]", + "open": "Actions on open" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "Template lock" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -265,6 +288,7 @@ "fan": "Template a fan", "image": "Template an image", "light": "Template a light", + "lock": "Template a lock", "number": "Template a number", "select": "Template a select", "sensor": "Template a sensor", @@ -495,6 +519,28 @@ }, "title": "[%key:component::template::config::step::light::title%]" }, + "lock": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "state": "[%key:component::template::common::state%]", + "lock": "[%key:component::template::config::step::lock::data::lock%]", + "unlock": "[%key:component::template::config::step::lock::data::unlock%]", + "code_format": "[%key:component::template::common::code_format%]", + "open": "[%key:component::template::config::step::lock::data::open%]" + }, + "data_description": { + "device_id": "[%key:component::template::common::device_id_description%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::template::common::advanced_options%]", + "data": { + "availability": "[%key:component::template::common::availability%]" + } + } + }, + "title": "[%key:component::template::config::step::lock::title%]" + }, "number": { "data": { "device_id": "[%key:common::config_flow::data::device%]", diff --git a/tests/components/template/snapshots/test_lock.ambr b/tests/components/template/snapshots/test_lock.ambr new file mode 100644 index 00000000000..250fc6ba8d4 --- /dev/null +++ b/tests/components/template/snapshots/test_lock.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 9bfb0d439f7..08104025582 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -179,6 +179,16 @@ BINARY_SENSOR_OPTIONS = { {"turn_on": [], "turn_off": []}, {}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + "locked", + {"one": "locked", "two": "unlocked"}, + {}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + {}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -372,6 +382,12 @@ async def test_config_flow( {"turn_on": [], "turn_off": []}, {"turn_on": [], "turn_off": []}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -603,6 +619,16 @@ async def test_config_flow_device( {"turn_on": [], "turn_off": []}, "state", ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"state": "{{ states('lock.two') }}"}, + ["locked", "unlocked"], + {"one": "locked", "two": "unlocked"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + "state", + ), ( "number", {"state": "{{ states('number.one') }}"}, @@ -1464,6 +1490,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( {"turn_on": [], "turn_off": []}, {"turn_on": [], "turn_off": []}, ), + ( + "lock", + {"state": "{{ states('lock.one') }}"}, + {"lock": [], "unlock": []}, + {"lock": [], "unlock": []}, + ), ( "number", {"state": "{{ states('number.one') }}"}, diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 457c5b7bf5c..823306015bf 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components import lock, template @@ -19,9 +20,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component +from tests.typing import WebSocketGenerator TEST_OBJECT_ID = "test_template_lock" TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" @@ -1186,3 +1188,58 @@ async def test_optimistic(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED + + +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests creating a lock from a config entry.""" + + hass.states.async_set( + "sensor.test_state", + LockState.LOCKED, + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{ states('sensor.test_state') }}", + "lock": [], + "unlock": [], + "template_type": lock.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("lock.my_template") + assert state is not None + assert state == snapshot + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + lock.DOMAIN, + { + "name": "My template", + "state": "{{ 'locked' }}", + "lock": [], + "unlock": [], + }, + ) + + assert state["state"] == LockState.LOCKED From 8193259e022ac1a21c6dc87e4a238320fc3cf7af Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Jul 2025 17:06:55 +0200 Subject: [PATCH 0777/1118] Revert "Add select for heating circuit to Tado zones" (#149670) --- homeassistant/components/tado/__init__.py | 1 - homeassistant/components/tado/coordinator.py | 62 +- homeassistant/components/tado/select.py | 108 ---- homeassistant/components/tado/strings.json | 8 - .../tado/fixtures/heating_circuits.json | 7 - .../tado/fixtures/zone_control.json | 80 --- .../tado/snapshots/test_diagnostics.ambr | 561 ------------------ tests/components/tado/test_select.py | 91 --- tests/components/tado/util.py | 12 - 9 files changed, 3 insertions(+), 927 deletions(-) delete mode 100644 homeassistant/components/tado/select.py delete mode 100644 tests/components/tado/fixtures/heating_circuits.json delete mode 100644 tests/components/tado/fixtures/zone_control.json delete mode 100644 tests/components/tado/test_select.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index df33845437f..0513d63b893 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -41,7 +41,6 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, - Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 79486ff998b..09c6ec40208 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -73,8 +73,6 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): "weather": {}, "geofence": {}, "zone": {}, - "zone_control": {}, - "heating_circuits": {}, } @property @@ -101,14 +99,11 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.home_name = tado_home["name"] devices = await self._async_update_devices() - zones, zone_controls = await self._async_update_zones() + zones = await self._async_update_zones() home = await self._async_update_home() - heating_circuits = await self._async_update_heating_circuits() self.data["device"] = devices self.data["zone"] = zones - self.data["zone_control"] = zone_controls - self.data["heating_circuits"] = heating_circuits self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] @@ -171,7 +166,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return mapped_devices - async def _async_update_zones(self) -> tuple[dict[int, dict], dict[int, dict]]: + async def _async_update_zones(self) -> dict[int, dict]: """Update the zone data from Tado.""" try: @@ -184,12 +179,10 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise UpdateFailed(f"Error updating Tado zones: {err}") from err mapped_zones: dict[int, dict] = {} - mapped_zone_controls: dict[int, dict] = {} for zone in zone_states: mapped_zones[int(zone)] = await self._update_zone(int(zone)) - mapped_zone_controls[int(zone)] = await self._update_zone_control(int(zone)) - return mapped_zones, mapped_zone_controls + return mapped_zones async def _update_zone(self, zone_id: int) -> dict[str, str]: """Update the internal data of a zone.""" @@ -206,24 +199,6 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) return data - async def _update_zone_control(self, zone_id: int) -> dict[str, Any]: - """Update the internal zone control data of a zone.""" - - _LOGGER.debug("Updating zone control for zone %s", zone_id) - try: - zone_control_data = await self.hass.async_add_executor_job( - self._tado.get_zone_control, zone_id - ) - except RequestException as err: - _LOGGER.error( - "Error updating Tado zone control for zone %s: %s", zone_id, err - ) - raise UpdateFailed( - f"Error updating Tado zone control for zone {zone_id}: {err}" - ) from err - - return zone_control_data - async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" @@ -242,23 +217,6 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return {"weather": weather, "geofence": geofence} - async def _async_update_heating_circuits(self) -> dict[str, dict]: - """Update the heating circuits data from Tado.""" - - try: - heating_circuits = await self.hass.async_add_executor_job( - self._tado.get_heating_circuits - ) - except RequestException as err: - _LOGGER.error("Error updating Tado heating circuits: %s", err) - raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err - - mapped_heating_circuits: dict[str, dict] = {} - for circuit in heating_circuits: - mapped_heating_circuits[circuit["driverShortSerialNo"]] = circuit - - return mapped_heating_circuits - async def get_capabilities(self, zone_id: int | str) -> dict: """Fetch the capabilities from Tado.""" @@ -406,20 +364,6 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc - async def set_heating_circuit(self, zone_id: int, circuit_id: int | None) -> None: - """Set heating circuit for zone.""" - try: - await self.hass.async_add_executor_job( - self._tado.set_zone_heating_circuit, - zone_id, - circuit_id, - ) - except RequestException as exc: - raise HomeAssistantError( - f"Error setting Tado heating circuit: {exc}" - ) from exc - await self._update_zone_control(zone_id) - class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py deleted file mode 100644 index 6db765128c2..00000000000 --- a/homeassistant/components/tado/select.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Module for Tado select entities.""" - -import logging - -from homeassistant.components.select import SelectEntity -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import TadoConfigEntry -from .entity import TadoDataUpdateCoordinator, TadoZoneEntity - -_LOGGER = logging.getLogger(__name__) - -NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit" - - -async def async_setup_entry( - hass: HomeAssistant, - entry: TadoConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the Tado select platform.""" - - tado = entry.runtime_data.coordinator - entities: list[SelectEntity] = [ - TadoHeatingCircuitSelectEntity(tado, zone["name"], zone["id"]) - for zone in tado.zones - if zone["type"] == "HEATING" - ] - - async_add_entities(entities, True) - - -class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity): - """Representation of a Tado heating circuit select entity.""" - - _attr_entity_category = EntityCategory.CONFIG - _attr_has_entity_name = True - _attr_icon = "mdi:water-boiler" - _attr_translation_key = "heating_circuit" - - def __init__( - self, - coordinator: TadoDataUpdateCoordinator, - zone_name: str, - zone_id: int, - ) -> None: - """Initialize the Tado heating circuit select entity.""" - super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) - - self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit" - - self._attr_options = [] - self._attr_current_option = None - - async def async_select_option(self, option: str) -> None: - """Update the selected heating circuit.""" - heating_circuit_id = ( - None - if option == NO_HEATING_CIRCUIT_OPTION - else self.coordinator.data["heating_circuits"].get(option, {}).get("number") - ) - await self.coordinator.set_heating_circuit(self.zone_id, heating_circuit_id) - await self.coordinator.async_request_refresh() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._async_update_callback() - super()._handle_coordinator_update() - - @callback - def _async_update_callback(self) -> None: - """Handle update callbacks.""" - # Heating circuits list - heating_circuits = self.coordinator.data["heating_circuits"].values() - self._attr_options = [NO_HEATING_CIRCUIT_OPTION] - self._attr_options.extend(hc["driverShortSerialNo"] for hc in heating_circuits) - - # Current heating circuit - zone_control = self.coordinator.data["zone_control"].get(self.zone_id) - if zone_control and "heatingCircuit" in zone_control: - heating_circuit_number = zone_control["heatingCircuit"] - if heating_circuit_number is None: - self._attr_current_option = NO_HEATING_CIRCUIT_OPTION - else: - # Find heating circuit by number - heating_circuit = next( - ( - hc - for hc in heating_circuits - if hc.get("number") == heating_circuit_number - ), - None, - ) - - if heating_circuit is None: - _LOGGER.error( - "Heating circuit with number %s not found for zone %s", - heating_circuit_number, - self.zone_name, - ) - self._attr_current_option = NO_HEATING_CIRCUIT_OPTION - else: - self._attr_current_option = heating_circuit.get( - "driverShortSerialNo" - ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index ba1c9e95683..5d9c4237be8 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -59,14 +59,6 @@ } } }, - "select": { - "heating_circuit": { - "name": "Heating circuit", - "state": { - "no_heating_circuit": "No circuit" - } - } - }, "switch": { "child_lock": { "name": "Child lock" diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json deleted file mode 100644 index 723ceb76f95..00000000000 --- a/tests/components/tado/fixtures/heating_circuits.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "number": 1, - "driverSerialNo": "RU1234567890", - "driverShortSerialNo": "RU1234567890" - } -] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json deleted file mode 100644 index 584fe9f3c92..00000000000 --- a/tests/components/tado/fixtures/zone_control.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "type": "HEATING", - "earlyStartEnabled": false, - "heatingCircuit": 1, - "duties": { - "type": "HEATING", - "leader": { - "deviceType": "RU01", - "serialNo": "RU1234567890", - "shortSerialNo": "RU1234567890", - "currentFwVersion": "54.20", - "connectionState": { - "value": true, - "timestamp": "2025-06-30T19:53:40.710Z" - }, - "characteristics": { - "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - }, - "batteryState": "NORMAL" - }, - "drivers": [ - { - "deviceType": "VA01", - "serialNo": "VA1234567890", - "shortSerialNo": "VA1234567890", - "currentFwVersion": "54.20", - "connectionState": { - "value": true, - "timestamp": "2025-06-30T19:54:15.166Z" - }, - "characteristics": { - "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - }, - "mountingState": { - "value": "CALIBRATED", - "timestamp": "2025-06-09T23:25:12.678Z" - }, - "mountingStateWithError": "CALIBRATED", - "batteryState": "LOW", - "childLockEnabled": false - } - ], - "uis": [ - { - "deviceType": "RU01", - "serialNo": "RU1234567890", - "shortSerialNo": "RU1234567890", - "currentFwVersion": "54.20", - "connectionState": { - "value": true, - "timestamp": "2025-06-30T19:53:40.710Z" - }, - "characteristics": { - "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - }, - "batteryState": "NORMAL" - }, - { - "deviceType": "VA01", - "serialNo": "VA1234567890", - "shortSerialNo": "VA1234567890", - "currentFwVersion": "54.20", - "connectionState": { - "value": true, - "timestamp": "2025-06-30T19:54:15.166Z" - }, - "characteristics": { - "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - }, - "mountingState": { - "value": "CALIBRATED", - "timestamp": "2025-06-09T23:25:12.678Z" - }, - "mountingStateWithError": "CALIBRATED", - "batteryState": "LOW", - "childLockEnabled": false - } - ] - } -} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index 34d26c222fa..eefb818a88c 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -62,13 +62,6 @@ 'presence': 'HOME', 'presenceLocked': False, }), - 'heating_circuits': dict({ - 'RU1234567890': dict({ - 'driverSerialNo': 'RU1234567890', - 'driverShortSerialNo': 'RU1234567890', - 'number': 1, - }), - }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46, @@ -117,560 +110,6 @@ '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)", }), }), - 'zone_control': dict({ - '1': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - '2': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - '3': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - '4': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - '5': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - '6': dict({ - 'duties': dict({ - 'drivers': list([ - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - 'leader': dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - 'type': 'HEATING', - 'uis': list([ - dict({ - 'batteryState': 'NORMAL', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:53:40.710Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'RU01', - 'serialNo': 'RU1234567890', - 'shortSerialNo': 'RU1234567890', - }), - dict({ - 'batteryState': 'LOW', - 'characteristics': dict({ - 'capabilities': list([ - 'INSIDE_TEMPERATURE_MEASUREMENT', - 'IDENTIFY', - ]), - }), - 'childLockEnabled': False, - 'connectionState': dict({ - 'timestamp': '2025-06-30T19:54:15.166Z', - 'value': True, - }), - 'currentFwVersion': '54.20', - 'deviceType': 'VA01', - 'mountingState': dict({ - 'timestamp': '2025-06-09T23:25:12.678Z', - 'value': 'CALIBRATED', - }), - 'mountingStateWithError': 'CALIBRATED', - 'serialNo': 'VA1234567890', - 'shortSerialNo': 'VA1234567890', - }), - ]), - }), - 'earlyStartEnabled': False, - 'heatingCircuit': 1, - 'type': 'HEATING', - }), - }), }), 'mobile_devices': dict({ 'mobile_device': dict({ diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py deleted file mode 100644 index e57b7510d1b..00000000000 --- a/tests/components/tado/test_select.py +++ /dev/null @@ -1,91 +0,0 @@ -"""The select tests for the tado platform.""" - -from unittest.mock import patch - -import pytest - -from homeassistant.components.select import ( - DOMAIN as SELECT_DOMAIN, - SERVICE_SELECT_OPTION, -) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION -from homeassistant.core import HomeAssistant - -from .util import async_init_integration - -HEATING_CIRCUIT_SELECT_ENTITY = "select.baseboard_heater_heating_circuit" -NO_HEATING_CIRCUIT = "no_heating_circuit" -HEATING_CIRCUIT_OPTION = "RU1234567890" -ZONE_ID = 1 -HEATING_CIRCUIT_ID = 1 - - -async def test_heating_circuit_select(hass: HomeAssistant) -> None: - """Test creation of heating circuit select entity.""" - - await async_init_integration(hass) - state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) - assert state is not None - assert state.state == HEATING_CIRCUIT_OPTION - assert NO_HEATING_CIRCUIT in state.attributes["options"] - assert HEATING_CIRCUIT_OPTION in state.attributes["options"] - - -@pytest.mark.parametrize( - ("option", "expected_circuit_id"), - [(HEATING_CIRCUIT_OPTION, HEATING_CIRCUIT_ID), (NO_HEATING_CIRCUIT, None)], -) -async def test_heating_circuit_select_action( - hass: HomeAssistant, option, expected_circuit_id -) -> None: - """Test selecting heating circuit option.""" - - await async_init_integration(hass) - - # Test selecting a specific heating circuit - with ( - patch( - "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_heating_circuit" - ) as mock_set_zone_heating_circuit, - patch( - "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_control" - ) as mock_get_zone_control, - ): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: HEATING_CIRCUIT_SELECT_ENTITY, - ATTR_OPTION: option, - }, - blocking=True, - ) - - mock_set_zone_heating_circuit.assert_called_with(ZONE_ID, expected_circuit_id) - assert mock_get_zone_control.called - - -@pytest.mark.usefixtures("caplog") -async def test_heating_circuit_not_found( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test when a heating circuit with a specific number is not found.""" - circuit_not_matching_zone_control = 999 - heating_circuits = [ - { - "number": circuit_not_matching_zone_control, - "driverSerialNo": "RU1234567890", - "driverShortSerialNo": "RU1234567890", - } - ] - - with patch( - "homeassistant.components.tado.PyTado.interface.api.Tado.get_heating_circuits", - return_value=heating_circuits, - ): - await async_init_integration(hass) - - state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) - assert state.state == NO_HEATING_CIRCUIT - - assert "Heating circuit with number 1 not found for zone" in caplog.text diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 5ef0ab5dbf2..8ee7209acb2 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,10 +20,8 @@ async def async_init_integration( me_fixture = "me.json" weather_fixture = "weather.json" home_fixture = "home.json" - home_heating_circuits_fixture = "heating_circuits.json" home_state_fixture = "home_state.json" zones_fixture = "zones.json" - zone_control_fixture = "zone_control.json" zone_states_fixture = "zone_states.json" # WR1 Device @@ -72,10 +70,6 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/", text=await async_load_fixture(hass, home_fixture, DOMAIN), ) - m.get( - "https://my.tado.com/api/v2/homes/1/heatingCircuits", - text=await async_load_fixture(hass, home_heating_circuits_fixture, DOMAIN), - ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), @@ -184,12 +178,6 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), ) - zone_ids = [1, 2, 3, 4, 5, 6] - for zone_id in zone_ids: - m.get( - f"https://my.tado.com/api/v2/homes/1/zones/{zone_id}/control", - text=await async_load_fixture(hass, zone_control_fixture, DOMAIN), - ) m.post( "https://login.tado.com/oauth2/token", text=await async_load_fixture(hass, token_fixture, DOMAIN), From 8114df42192faac7af9e640db0a16deae37a6255 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 30 Jul 2025 18:36:20 +0200 Subject: [PATCH 0778/1118] Bump version to 2025.9.0 (#149680) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ce7cf1ac124..e96de66ac76 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 4 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.8" + HA_SHORT_VERSION: "2025.9" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 2daa6d91db2..1983932813e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 8 +MINOR_VERSION: Final = 9 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 35a2bf2c7fb..71182e99560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.8.0.dev0" +version = "2025.9.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 736d582d049a2d02b7c494c23ffbd529ab6bc64f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Jul 2025 18:53:21 +0200 Subject: [PATCH 0779/1118] Fix translation string reference for MQTT climate subentry option (#149673) --- homeassistant/components/mqtt/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 22fb85780b0..c14bda008d1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -426,7 +426,7 @@ }, "data_description": { "payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]", - "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]", + "payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_on%]", "power_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the power command topic. The `value` parameter is the payload set for payload \"on\" or payload \"off\".", "power_command_topic": "The MQTT topic to publish commands to change the climate power state. Sends the payload configured with payload \"on\" or payload \"off\". [Learn more.]({url}#power_command_topic)" } @@ -812,7 +812,7 @@ "min_humidity": "The minimum target humidity that can be set.", "target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the humidity command topic.", "target_humidity_command_topic": "The MQTT topic to publish commands to change the climate target humidity. [Learn more.]({url}#humidity_command_topic)", - "target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the humidity state topic with.", + "target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the target humidity state topic with.", "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" } }, From 09b91bd76afac3fa90ddad4f54bbb0f9611999f0 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 30 Jul 2025 19:48:36 +0200 Subject: [PATCH 0780/1118] Clean airq tests (#149682) --- tests/components/airq/common.py | 18 ++++++++++++++++++ tests/components/airq/test_config_flow.py | 17 ++++------------- tests/components/airq/test_coordinator.py | 12 ++---------- 3 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 tests/components/airq/common.py diff --git a/tests/components/airq/common.py b/tests/components/airq/common.py new file mode 100644 index 00000000000..275da60e3a2 --- /dev/null +++ b/tests/components/airq/common.py @@ -0,0 +1,18 @@ +"""Common methods used across tests for air-Q.""" + +from aioairq import DeviceInfo + +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD + +TEST_USER_DATA = { + CONF_IP_ADDRESS: "192.168.0.0", + CONF_PASSWORD: "password", +} +TEST_DEVICE_INFO = DeviceInfo( + id="id", + name="name", + model="model", + sw_version="sw", + hw_version="hw", +) +TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 09da6343e05..95c22cb12c8 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -3,7 +3,7 @@ import logging from unittest.mock import patch -from aioairq import DeviceInfo, InvalidAuth +from aioairq import InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import pytest @@ -13,25 +13,16 @@ from homeassistant.components.airq.const import ( CONF_RETURN_AVERAGE, DOMAIN, ) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .common import TEST_DEVICE_INFO, TEST_USER_DATA + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -TEST_USER_DATA = { - CONF_IP_ADDRESS: "192.168.0.0", - CONF_PASSWORD: "password", -} -TEST_DEVICE_INFO = DeviceInfo( - id="id", - name="name", - model="model", - sw_version="sw", - hw_version="hw", -) DEFAULT_OPTIONS = { CONF_CLIP_NEGATIVE: True, CONF_RETURN_AVERAGE: True, diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py index 69f7c9dee17..6512d60ddbe 100644 --- a/tests/components/airq/test_coordinator.py +++ b/tests/components/airq/test_coordinator.py @@ -3,7 +3,6 @@ import logging from unittest.mock import patch -from aioairq import DeviceInfo as AirQDeviceInfo import pytest from homeassistant.components.airq import AirQCoordinator @@ -12,9 +11,10 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO + from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry") MOCKED_ENTRY = MockConfigEntry( domain=DOMAIN, data={ @@ -24,14 +24,6 @@ MOCKED_ENTRY = MockConfigEntry( unique_id="123-456", ) -TEST_DEVICE_INFO = AirQDeviceInfo( - id="id", - name="name", - model="model", - sw_version="sw", - hw_version="hw", -) -TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} STATUS_WARMUP = { "co": "co sensor still in warm up phase; waiting time = 18 s", "tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s", From a76af50c10da2ce258a15e8026606365b4932171 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 30 Jul 2025 12:57:59 -0500 Subject: [PATCH 0781/1118] Bump intents to 2025.7.30 (#149678) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/assist_pipeline/test_pipeline.py | 4 ++-- tests/components/conversation/snapshots/test_http.ambr | 8 ++++---- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ad0a4c96102..31adffad064 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.7.30"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 819bb2f5c9a..704fb282784 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.110.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250730.0 -home-assistant-intents==2025.6.23 +home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index f731ecc0e0d..c01d9eef347 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1177,7 +1177,7 @@ holidays==0.77 home-assistant-frontend==20250730.0 # homeassistant.components.conversation -home-assistant-intents==2025.6.23 +home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud homematicip==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64931e1ef4e..eada71b4f02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1026,7 +1026,7 @@ holidays==0.77 home-assistant-frontend==20250730.0 # homeassistant.components.conversation -home-assistant-intents==2025.6.23 +home-assistant-intents==2025.7.30 # homeassistant.components.homematicip_cloud homematicip==2.2.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5168388c934..5776f6dfe12 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==2.2.3 \ - home-assistant-intents==2025.6.23 \ + home-assistant-intents==2025.7.30 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 5bc7b86c38c..0cb67302700 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -375,7 +375,7 @@ async def test_get_pipelines(hass: HomeAssistant) -> None: ("en", "us", "en", "en"), ("en", "uk", "en", "en"), ("pt", "pt", "pt", "pt"), - ("pt", "br", "pt-br", "pt"), + ("pt", "br", "pt-BR", "pt"), ], ) async def test_default_pipeline_no_stt_tts( @@ -428,7 +428,7 @@ async def test_default_pipeline_no_stt_tts( ("en", "us", "en", "en", "en", "en"), ("en", "uk", "en", "en", "en", "en"), ("pt", "pt", "pt", "pt", "pt", "pt"), - ("pt", "br", "pt-br", "pt", "pt-br", "pt-br"), + ("pt", "br", "pt-BR", "pt", "pt-br", "pt-br"), ], ) @pytest.mark.usefixtures("init_supporting_components") diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 391fb609d65..8f68274d37f 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -45,7 +45,7 @@ 'nl', 'pl', 'pt', - 'pt-br', + 'pt-BR', 'ro', 'ru', 'sk', @@ -60,9 +60,9 @@ 'uk', 'ur', 'vi', - 'zh-cn', - 'zh-hk', - 'zh-tw', + 'zh-CN', + 'zh-HK', + 'zh-TW', ]), }), dict({ From 8d27ca1e21547978a38d702346a9089e80204d10 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:59:01 +0200 Subject: [PATCH 0782/1118] Fix `KeyError` in friends coordinator (#149684) --- .../components/playstation_network/coordinator.py | 8 +++++--- tests/components/playstation_network/conftest.py | 8 ++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index c447e8dc503..977632de23b 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -6,7 +6,7 @@ from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from psnawp_api.core.psnawp_exceptions import ( PSNAWPAuthenticationError, @@ -29,7 +29,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ACCOUNT_ID, DOMAIN +from .const import DOMAIN from .helpers import PlaystationNetwork, PlaystationNetworkData _LOGGER = logging.getLogger(__name__) @@ -176,7 +176,9 @@ class PlaystationNetworkFriendDataCoordinator( def _setup(self) -> None: """Set up the coordinator.""" - self.user = self.psn.psn.user(account_id=self.subentry.data[CONF_ACCOUNT_ID]) + if TYPE_CHECKING: + assert self.subentry.unique_id + self.user = self.psn.psn.user(account_id=self.subentry.unique_id) self.profile = self.user.profile() async def _async_setup(self) -> None: diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index ab4edc0e3f4..bfbdc9a72bd 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -14,11 +14,7 @@ from psnawp_api.models.trophies import ( ) import pytest -from homeassistant.components.playstation_network.const import ( - CONF_ACCOUNT_ID, - CONF_NPSSO, - DOMAIN, -) +from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN from homeassistant.config_entries import ConfigSubentryData from tests.common import MockConfigEntry @@ -40,7 +36,7 @@ def mock_config_entry() -> MockConfigEntry: unique_id=PSN_ID, subentries_data=[ ConfigSubentryData( - data={CONF_ACCOUNT_ID: "fren-psn-id"}, + data={}, subentry_id="ABCDEF", subentry_type="friend", title="PublicUniversalFriend", From 389a1251a140b79c6bdad587930eccf514d488b2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:59:41 -0400 Subject: [PATCH 0783/1118] Bump ZHA to 0.0.64 (#149683) Co-authored-by: TheJulianJES Co-authored-by: abmantis --- homeassistant/components/zha/helpers.py | 22 +++++++++++++++- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 30 ++++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/test_update.py | 25 +++++++++++++++++- 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 084e1c882ac..f5b44eb8fc4 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -74,7 +74,12 @@ from zha.event import EventBase from zha.exceptions import ZHAException from zha.mixins import LogMixin from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent -from zha.zigbee.device import ClusterHandlerConfigurationComplete, Device, ZHAEvent +from zha.zigbee.device import ( + ClusterHandlerConfigurationComplete, + Device, + DeviceFirmwareInfoUpdatedEvent, + ZHAEvent, +) from zha.zigbee.group import Group, GroupInfo, GroupMember from zigpy.config import ( CONF_DATABASE, @@ -843,8 +848,23 @@ class ZHAGatewayProxy(EventBase): name=zha_device.name, manufacturer=zha_device.manufacturer, model=zha_device.model, + sw_version=zha_device.firmware_version, ) zha_device_proxy.device_id = device_registry_device.id + + def update_sw_version(event: DeviceFirmwareInfoUpdatedEvent) -> None: + """Update software version in device registry.""" + device_registry.async_update_device( + device_registry_device.id, + sw_version=event.new_firmware_version, + ) + + self._unsubs.append( + zha_device.on_event( + DeviceFirmwareInfoUpdatedEvent.event_type, update_sw_version + ) + ) + return zha_device_proxy def _async_get_or_create_group_proxy(self, group_info: GroupInfo) -> ZHAGroupProxy: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2cbc962a305..ec08c4f5d9d 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.62"], + "requirements": ["zha==0.0.64"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 23d17ea128f..1c9454ec0a0 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -616,6 +616,18 @@ }, "water_supply": { "name": "Water supply" + }, + "frient_in_1": { + "name": "IN1" + }, + "frient_in_2": { + "name": "IN2" + }, + "frient_in_3": { + "name": "IN3" + }, + "frient_in_4": { + "name": "IN4" } }, "button": { @@ -639,6 +651,9 @@ }, "frost_lock_reset": { "name": "Frost lock reset" + }, + "reset_alarm": { + "name": "Reset alarm" } }, "climate": { @@ -1472,6 +1487,9 @@ "tier6_summation_delivered": { "name": "Tier 6 summation delivered" }, + "total_active_power": { + "name": "Total power" + }, "summation_received": { "name": "Summation received" }, @@ -2006,6 +2024,18 @@ }, "auto_relock": { "name": "Autorelock" + }, + "distance_tracking": { + "name": "Distance tracking" + }, + "water_shortage_auto_close": { + "name": "Water shortage auto-close" + }, + "frient_com_1": { + "name": "COM 1" + }, + "frient_com_2": { + "name": "COM 2" } } } diff --git a/requirements_all.txt b/requirements_all.txt index c01d9eef347..f5f0c5116dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3203,7 +3203,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.62 +zha==0.0.64 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eada71b4f02..9336bbcc68c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.62 +zha==0.0.64 # homeassistant.components.zwave_js zwave-js-server-python==0.67.0 diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index c8cbc407106..04d190b170c 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -47,6 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .common import find_entity_id, update_attribute_cache @@ -156,7 +157,6 @@ async def setup_test_data( ) ) zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) - zha_device_proxy.device.async_update_sw_build_id(installed_fw_version) return zha_device_proxy, cluster, fw_image, installed_fw_version @@ -643,3 +643,26 @@ async def test_update_release_notes( assert "Some lengthy release notes" in result["result"] assert OTA_MESSAGE_RELIABILITY in result["result"] assert OTA_MESSAGE_BATTERY_POWERED in result["result"] + + +async def test_update_version_sync_device_registry( + hass: HomeAssistant, + setup_zha, + zigpy_device_mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test firmware version syncing between the ZHA device and Home Assistant.""" + await setup_zha() + zha_device, _, _, _ = await setup_test_data(hass, zigpy_device_mock) + + zha_device.device.async_update_firmware_version("0x12345678") + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.device.ieee))} + ) + assert reg_device.sw_version == "0x12345678" + + zha_device.device.async_update_firmware_version("0xabcd1234") + reg_device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.device.ieee))} + ) + assert reg_device.sw_version == "0xabcd1234" From 1ead01bc9a85df0917f3a5a68be9c68408580818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 30 Jul 2025 20:19:01 +0200 Subject: [PATCH 0784/1118] Explicitly pass config_entry to miele coordinator (#149691) --- homeassistant/components/miele/__init__.py | 2 +- homeassistant/components/miele/coordinator.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 1cb2fc0fab1..2c5c250aee7 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> boo ) from err # Setup MieleAPI and coordinator for data fetch - coordinator = MieleDataUpdateCoordinator(hass, auth) + coordinator = MieleDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index 27456ffe04c..d5de2d79cb9 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -42,12 +42,14 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): def __init__( self, hass: HomeAssistant, + config_entry: MieleConfigEntry, api: AsyncConfigEntryAuth, ) -> None: """Initialize the Miele data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=120), ) From b4e50902ebed2152a7eef4164112c3034a1a3c1d Mon Sep 17 00:00:00 2001 From: Roman Sivriver Date: Wed, 30 Jul 2025 17:29:26 -0400 Subject: [PATCH 0785/1118] Fix typo in backup log message (#149705) --- homeassistant/components/backup/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e7fc1262f6d..f1b2f7d5b97 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1119,7 +1119,7 @@ class BackupManager: ) if unavailable_agents: LOGGER.warning( - "Backup agents %s are not available, will backupp to %s", + "Backup agents %s are not available, will backup to %s", unavailable_agents, available_agents, ) From 2706c7d67d27e3a839e9eb890a6b93e8ee054c05 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:30:05 -0400 Subject: [PATCH 0786/1118] Add translations for all fields in template integration (#149692) Co-authored-by: Norbert Rittel --- .../components/template/strings.json | 238 +++++++++++++++--- 1 file changed, 209 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index edf4516e8ab..b412fa519cd 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -2,6 +2,7 @@ "common": { "advanced_options": "Advanced options", "availability": "Availability template", + "availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.", "code_format": "Code format", "device_class": "Device class", "device_id_description": "Select a device to link to this entity.", @@ -28,13 +29,26 @@ "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`.", + "disarm": "Defines actions to run when the alarm control panel is disarmed. Receives variable `code`.", + "arm_away": "Defines actions to run when the alarm control panel is armed to `arm_away`. Receives variable `code`.", + "arm_custom_bypass": "Defines actions to run when the alarm control panel is armed to `arm_custom_bypass`. Receives variable `code`.", + "arm_home": "Defines actions to run when the alarm control panel is armed to `arm_home`. Receives variable `code`.", + "arm_night": "Defines actions to run when the alarm control panel is armed to `arm_night`. Receives variable `code`.", + "arm_vacation": "Defines actions to run when the alarm control panel is armed to `arm_vacation`. Receives variable `code`.", + "trigger": "Defines actions to run when the alarm control panel is triggered. Receives variable `code`.", + "code_arm_required": "If true, the code is required to arm the alarm.", + "code_format": "One of number, text or no_code. Format for the code used to arm/disarm the alarm." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -48,13 +62,17 @@ "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -68,13 +86,17 @@ "press": "Actions on press" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "press": "Defines actions to run when button is pressed." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -99,13 +121,16 @@ "close_cover": "Defines actions to run when the cover is closed.", "stop_cover": "Defines actions to run when the cover is stopped.", "position": "Defines a template to get the position of the cover. Value values are numbers between `0` (`closed`) and `100` (`open`).", - "set_cover_position": "Defines actions to run when the cover is given a `set_cover_position` command." + "set_cover_position": "Defines actions to run when the cover is given a `set_cover_position` command. Receives variable `position`." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -124,11 +149,11 @@ }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", - "state": "Defines a template to get the state of the fan. Valid values: `on`, `off`.", + "state": "The fan is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`.", "turn_off": "Defines actions to run when the fan is turned off.", - "turn_on": "Defines actions to run when the fan is turned on.", + "turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`.", "percentage": "Defines a template to get the speed percentage of the fan.", - "set_percentage": "Defines actions to run when the fan is given a speed percentage command.", + "set_percentage": "Defines actions to run when the fan is given a speed percentage command. Receives variable `percentage`.", "speed_count": "The number of speeds the fan supports. Used to calculate the percentage step for the `fan.increase_speed` and `fan.decrease_speed` actions." }, "sections": { @@ -136,6 +161,9 @@ "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -149,13 +177,18 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "url": "Defines a template to get the URL on which the image is served.", + "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http-only URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -176,13 +209,25 @@ "set_temperature": "Actions on set color temperature" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "The light is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`.", + "turn_off": "Defines actions to run when the light is turned off.", + "turn_on": "Defines actions to run when the light is turned on.", + "level": "Defines a template to get the brightness of the light. Valid values are 0 to 255.", + "set_level": "Defines actions to run when the light is given a brightness command. The script will only be called if the `turn_on` call only has `brightness`, and optionally `transition`. Receives variables `brightness` and, optionally, `transition`.", + "hs": "Defines a template to get the HS color of the light. Must render a tuple (hue, saturation).", + "set_hs": "Defines actions to run when the light is given a hs color command. Available variables: `hs` as a tuple, `h` and `s`.", + "temperature": "Defines a template to get the color temperature of the light.", + "set_temperature": "Defines actions to run when the light is given a color temperature command. Receives variable `color_temp_kelvin`. May also receive variables `brightness` and/or `transition`." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -199,13 +244,21 @@ "open": "Actions on open" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to set the state of the lock. The lock is locked if the template evaluates to `True`, `true`, `on`, or `locked`. The lock is unlocked if the template evaluates to `False`, `false`, `off`, or `unlocked`. Other valid states are `jammed`, `opening`, `locking`, `open`, and `unlocking`.", + "lock": "Defines actions to run when the lock is locked.", + "unlock": "Defines actions to run when the lock is unlocked.", + "code_format": "Defines a template to get the `code_format` attribute of the lock.", + "open": "Defines actions to run when the lock is opened." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -223,13 +276,22 @@ "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Template for the number's current value.", + "step": "Template for the number's increment/decrement step.", + "set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.", + "max": "Template for the number's maximum value.", + "min": "Template for the number's minimum value.", + "unit_of_measurement": "Defines the units of measurement of the number, if any." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -244,13 +306,19 @@ "options": "Available options" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Template for the select’s current value.", + "select_option": "Defines actions to run when an `option` from the `options` list is selected. Receives variable `option`.", + "options": "Template for the select’s available options." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -266,13 +334,18 @@ "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the sensor. If the sensor is numeric, i.e. it has a `state_class` or a `unit_of_measurement`, the state template must render to a number or to `none`. The state template must not render to a string, including `unknown` or `unavailable`. An `availability` template may be defined to suppress rendering of the state template.", + "unit_of_measurement": "Defines the units of measurement of the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -307,13 +380,18 @@ }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", - "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." + "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful.", + "turn_off": "Defines actions to run when the switch is turned off.", + "turn_on": "Defines actions to run when the switch is turned on." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -324,24 +402,37 @@ "device_id": "[%key:common::config_flow::data::device%]", "name": "[%key:common::config_flow::data::name%]", "state": "[%key:component::template::common::state%]", - "start": "Actions on turn off", + "start": "Actions on start", "fan_speed": "Fan speed", "fan_speeds": "Fan speeds", "set_fan_speed": "Actions on set fan speed", "stop": "Actions on stop", "pause": "Actions on pause", - "return_to_base": "Actions on return to base", + "return_to_base": "Actions on return to dock", "clean_spot": "Actions on clean spot", "locate": "Actions on locate" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "Defines a template to get the state of the vacuum. Valid values are `cleaning`, `docked`, `idle`, `paused`, `returning`, and `error`.", + "start": "Defines actions to run when the vacuum is started.", + "fan_speed": "Defines a template to get the fan speed of the vacuum.", + "fan_speeds": "List of fan speeds supported by the vacuum.", + "set_fan_speed": "Defines actions to run when the vacuum is given a command to set the fan speed. Receives variable `fan_speed`", + "stop": "Defines actions to run when the vacuum is stopped.", + "pause": "Defines actions to run when the vacuum is paused.", + "return_to_base": "Defines actions to run when the vacuum is given a 'Return to dock' command.", + "clean_spot": "Defines actions to run when the vacuum is given a 'Clean spot' command.", + "locate": "Defines actions to run when the vacuum is given a 'Locate' command." }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -366,13 +457,26 @@ "code_format": "[%key:component::template::common::code_format%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]", + "disarm": "[%key:component::template::config::step::alarm_control_panel::data_description::disarm%]", + "arm_away": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_away%]", + "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_custom_bypass%]", + "arm_home": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_home%]", + "arm_night": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_night%]", + "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data_description::arm_vacation%]", + "trigger": "[%key:component::template::config::step::alarm_control_panel::data_description::trigger%]", + "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data_description::code_arm_required%]", + "code_format": "[%key:component::template::config::step::alarm_control_panel::data_description::code_format%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -384,13 +488,17 @@ "state": "[%key:component::template::common::state%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::binary_sensor::data_description::state%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -402,13 +510,17 @@ "press": "[%key:component::template::config::step::button::data::press%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "press": "[%key:component::template::config::step::button::data_description::press%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -439,6 +551,9 @@ "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -468,6 +583,9 @@ "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -480,13 +598,18 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "url": "[%key:component::template::config::step::image::data_description::url%]", + "verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -507,13 +630,25 @@ "set_temperature": "[%key:component::template::config::step::light::data::set_temperature%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::light::data_description::state%]", + "turn_off": "[%key:component::template::config::step::light::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]", + "level": "[%key:component::template::config::step::light::data_description::level%]", + "set_level": "[%key:component::template::config::step::light::data_description::set_level%]", + "hs": "[%key:component::template::config::step::light::data_description::hs%]", + "set_hs": "[%key:component::template::config::step::light::data_description::set_hs%]", + "temperature": "[%key:component::template::config::step::light::data_description::temperature%]", + "set_temperature": "[%key:component::template::config::step::light::data_description::set_temperature%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -529,13 +664,21 @@ "open": "[%key:component::template::config::step::lock::data::open%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::lock::data_description::state%]", + "lock": "[%key:component::template::config::step::lock::data_description::lock%]", + "unlock": "[%key:component::template::config::step::lock::data_description::unlock%]", + "code_format": "[%key:component::template::config::step::lock::data_description::code_format%]", + "open": "[%key:component::template::config::step::lock::data_description::open%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -552,13 +695,21 @@ "min": "[%key:component::template::config::step::number::data::min%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::number::data_description::state%]", + "step": "[%key:component::template::config::step::number::data_description::step%]", + "set_value": "[%key:component::template::config::step::number::data_description::set_value%]", + "max": "[%key:component::template::config::step::number::data_description::max%]", + "min": "[%key:component::template::config::step::number::data_description::min%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -573,13 +724,19 @@ "options": "[%key:component::template::config::step::select::data::options%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::select::data_description::state%]", + "select_option": "[%key:component::template::config::step::select::data_description::select_option%]", + "options": "[%key:component::template::config::step::select::data_description::options%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -594,13 +751,18 @@ "unit_of_measurement": "[%key:component::template::common::unit_of_measurement%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::sensor::data_description::state%]", + "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::state%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -616,13 +778,18 @@ }, "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", - "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" + "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]", + "turn_off": "[%key:component::template::config::step::switch::data_description::turn_off%]", + "turn_on": "[%key:component::template::config::step::switch::data_description::turn_on%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, @@ -644,17 +811,30 @@ "locate": "[%key:component::template::config::step::vacuum::data::locate%]" }, "data_description": { - "device_id": "[%key:component::template::common::device_id_description%]" + "device_id": "[%key:component::template::common::device_id_description%]", + "state": "[%key:component::template::config::step::vacuum::data_description::state%]", + "start": "[%key:component::template::config::step::vacuum::data_description::start%]", + "fan_speed": "[%key:component::template::config::step::vacuum::data_description::fan_speed%]", + "fan_speeds": "[%key:component::template::config::step::vacuum::data_description::fan_speeds%]", + "set_fan_speed": "[%key:component::template::config::step::vacuum::data_description::set_fan_speed%]", + "stop": "[%key:component::template::config::step::vacuum::data_description::stop%]", + "pause": "[%key:component::template::config::step::vacuum::data_description::pause%]", + "return_to_base": "[%key:component::template::config::step::vacuum::data_description::return_to_base%]", + "clean_spot": "[%key:component::template::config::step::vacuum::data_description::clean_spot%]", + "locate": "[%key:component::template::config::step::vacuum::data_description::locate%]" }, "sections": { "advanced_options": { "name": "[%key:component::template::common::advanced_options%]", "data": { "availability": "[%key:component::template::common::availability%]" + }, + "data_description": { + "availability": "[%key:component::template::common::availability_description%]" } } }, - "title": "Template vacuum" + "title": "[%key:component::template::config::step::vacuum::title%]" } } }, From ec7fb140ac43bf54f69ba83fbac39fb61df89405 Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Wed, 30 Jul 2025 23:38:11 +0200 Subject: [PATCH 0787/1118] Fix Miele induction hob empty state (#149706) --- homeassistant/components/miele/sensor.py | 2 +- .../miele/snapshots/test_sensor.ambr | 1137 +++++++++++++++++ tests/components/miele/test_sensor.py | 15 + 3 files changed, 1153 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 216b91ca68e..cc108841aae 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -731,7 +731,7 @@ class MielePlateSensor(MieleSensor): ) ).name if self.device.state_plate_step - else PlatePowerStep.plate_step_0 + else PlatePowerStep.plate_step_0.name ) diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 915eda4d361..2805a683077 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,1141 @@ # serializer version: 1 +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_74-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pot-steam-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_74_off-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction', + 'icon': 'mdi:pot-steam-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_1_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 1', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_1_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 1', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_1_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_3', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 2', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_7', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_3_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_3_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 3', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_3_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_15', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_4_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 4', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74_off-state_plate_step-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_4_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 4', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_4_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_0', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plate 5', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plate', + 'unique_id': 'DummyAppliance_74-state_plate_step-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction_plate_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hob with extraction Plate 5', + 'options': list([ + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_14', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', + ]), + }), + 'context': , + 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'plate_step_boost', + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:turbine', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_18-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Hood', + 'icon': 'mdi:turbine', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index f35404a665b..e5051a683c9 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -256,3 +256,18 @@ async def test_vacuum_sensor_states( """Test robot vacuum cleaner sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["fan_devices.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fan_hob_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot fan / hob sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From f318766021f49238c9511ec27f5e4a923105acca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 30 Jul 2025 23:42:53 +0200 Subject: [PATCH 0788/1118] Fix inconsistent use of the term 'target' and a typo in MQTT translation strings (#149703) --- homeassistant/components/mqtt/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index c14bda008d1..40215b0f2c6 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -802,15 +802,15 @@ "data": { "max_humidity": "Maximum humidity", "min_humidity": "Minimum humidity", - "target_humidity_command_template": "Humidity command template", - "target_humidity_command_topic": "Humidity command topic", - "target_humidity_state_template": "Humidity state template", - "target_humidity_state_topic": "Humidity state topic" + "target_humidity_command_template": "Target humidity command template", + "target_humidity_command_topic": "Target humidity command topic", + "target_humidity_state_template": "Target humidity state template", + "target_humidity_state_topic": "Target humidity state topic" }, "data_description": { "max_humidity": "The maximum target humidity that can be set.", "min_humidity": "The minimum target humidity that can be set.", - "target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the humidity command topic.", + "target_humidity_command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to compose the payload to be published at the target humidity command topic.", "target_humidity_command_topic": "The MQTT topic to publish commands to change the climate target humidity. [Learn more.]({url}#humidity_command_topic)", "target_humidity_state_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to render the value received on the target humidity state topic with.", "target_humidity_state_topic": "The MQTT topic to subscribe for changes of the target humidity. [Learn more.]({url}#humidity_state_topic)" @@ -838,7 +838,7 @@ "temperature_low_state_topic": "Lower temperature state topic" }, "data_description": { - "initial": "The climate initalizes with this target temperature.", + "initial": "The climate initializes with this target temperature.", "max_temp": "The maximum target temperature that can be set.", "min_temp": "The minimum target temperature that can be set.", "precision": "The precision in degrees the thermostat is working at.", From 2cf144fb254b7950c27ea399cc1015fcb5c0113e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 30 Jul 2025 23:45:05 +0200 Subject: [PATCH 0789/1118] Add missing translations for miele dishwasher (#149702) --- homeassistant/components/miele/const.py | 10 ++++++++++ homeassistant/components/miele/strings.json | 3 +++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index a40df909e14..e8b626af785 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -431,6 +431,16 @@ DISHWASHER_PROGRAM_ID: dict[int, str] = { 38: "quick_power_wash", 42: "tall_items", 44: "power_wash", + 200: "eco", + 202: "automatic", + 203: "comfort_wash", + 204: "power_wash", + 205: "intensive", + 207: "extra_quiet", + 209: "comfort_wash_plus", + 210: "gentle", + 214: "maintenance", + 215: "rinse_salt", } TUMBLE_DRYER_PROGRAM_ID: dict[int, str] = { -1: "no_program", # Extrapolated from other device types. diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 01f13c8550d..a4400ff26eb 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -485,6 +485,8 @@ "cook_bacon": "Cook bacon", "biscuits_short_crust_pastry_1_tray": "Biscuits, short crust pastry (1 tray)", "biscuits_short_crust_pastry_2_trays": "Biscuits, short crust pastry (2 trays)", + "comfort_wash": "Comfort wash", + "comfort_wash_plus": "Comfort wash plus", "cool_air": "Cool air", "corn_on_the_cob": "Corn on the cob", "cottons": "Cottons", @@ -827,6 +829,7 @@ "rice_pudding_steam_cooking": "Rice pudding (steam cooking)", "rinse": "Rinse", "rinse_out_lint": "Rinse out lint", + "rinse_salt": "Rinse salt", "risotto": "Risotto", "ristretto": "Ristretto", "roast_beef_low_temperature_cooking": "Roast beef (low temperature cooking)", From 94dc2e2ea302a7e6ef64319c8f5d37987ec5c35f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 30 Jul 2025 23:54:32 +0200 Subject: [PATCH 0790/1118] Bump reolink-aio to 0.14.5 (#149700) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 39541476429..efd9f1121b6 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.4"] + "requirements": ["reolink-aio==0.14.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f5f0c5116dc..23ff02d69c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2666,7 +2666,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.4 +reolink-aio==0.14.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9336bbcc68c..9ede8c8f89b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2212,7 +2212,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.4 +reolink-aio==0.14.5 # homeassistant.components.rflink rflink==0.0.67 From f9e7459901c14b9015ac16fcb0746d941c9612de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 13:06:08 -1000 Subject: [PATCH 0791/1118] Fix ESPHome unnecessary probing on DHCP discovery (#149713) --- .../components/esphome/config_flow.py | 7 ++-- tests/components/esphome/test_config_flow.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index dc0e9b8e1b1..4efb0e494ef 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -316,10 +316,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Don't call _fetch_device_info() for ignored entries raise AbortFlow("already_configured") configured_host: str | None = entry.data.get(CONF_HOST) - configured_port: int | None = entry.data.get(CONF_PORT) - if configured_host == host and configured_port == port: + configured_port: int = entry.data.get(CONF_PORT, DEFAULT_PORT) + # When port is None (from DHCP discovery), only compare hosts + if configured_host == host and (port is None or configured_port == port): # Don't probe to verify the mac is correct since - # the host and port matches. + # the host matches (and port matches if provided). raise AbortFlow("already_configured") configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) await self._fetch_device_info(host, port or configured_port, configured_psk) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index d76991a984c..0fda7714dd0 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -2485,3 +2485,36 @@ async def test_reconfig_name_conflict_overwrite( ) is None ) + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_dhcp_no_probe_same_host_port_none( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test dhcp discovery does not probe when host matches and port is None.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + # DHCP discovery with same MAC and host (WiFi device) + service_info = DhcpServiceInfo( + ip="192.168.43.183", + hostname="test8266", + macaddress="11:22:33:44:55:aa", # Same MAC as configured + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Verify device_info was NOT called (no probing) + mock_client.device_info.assert_not_called() + + # Host should remain unchanged + assert entry.data[CONF_HOST] == "192.168.43.183" From 7a55373b0b4a58da107f7905fcb1aadf26fb0c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 31 Jul 2025 01:07:12 +0200 Subject: [PATCH 0792/1118] Fix bug when interpreting miele action response (#149710) --- homeassistant/components/miele/services.py | 2 +- tests/components/miele/fixtures/programs.json | 4 ++++ tests/components/miele/snapshots/test_services.ambr | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 9854196ea65..517b489173d 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -203,7 +203,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse: else {} ), } - if item["parameters"] + if item.get("parameters") else {} ), } diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json index ce2348f61de..1c232059d59 100644 --- a/tests/components/miele/fixtures/programs.json +++ b/tests/components/miele/fixtures/programs.json @@ -30,5 +30,9 @@ "mandatory": true } } + }, + { + "programId": 24000, + "program": "Ristretto" } ] diff --git a/tests/components/miele/snapshots/test_services.ambr b/tests/components/miele/snapshots/test_services.ambr index 3095ec9b6fb..3c3feca7832 100644 --- a/tests/components/miele/snapshots/test_services.ambr +++ b/tests/components/miele/snapshots/test_services.ambr @@ -43,6 +43,12 @@ 'program': 'Fan plus', 'program_id': 13, }), + dict({ + 'parameters': dict({ + }), + 'program': 'Ristretto', + 'program_id': 24000, + }), ]), }) # --- From 63216b77c27f8755df393ae2a70c473929305959 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jul 2025 13:54:18 -1000 Subject: [PATCH 0793/1118] Bump aioesphomeapi to 37.1.6 (#149715) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 00d56955aa7..355089555c5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.1.5", + "aioesphomeapi==37.1.6", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 23ff02d69c2..2af9a9f712d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.5 +aioesphomeapi==37.1.6 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ede8c8f89b..bbfcb8b2435 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.5 +aioesphomeapi==37.1.6 # homeassistant.components.flo aioflo==2021.11.0 From ad0db5c83afd0bda54d6f0277fdab79654611a84 Mon Sep 17 00:00:00 2001 From: johanzander Date: Thu, 31 Jul 2025 08:17:33 +0200 Subject: [PATCH 0794/1118] Update growattServer to version 1.7.1 (#149716) --- homeassistant/components/growatt_server/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 7b3e67228b1..b6a730835bb 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.6.0"] + "requirements": ["growattServer==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2af9a9f712d..3c3e421f5e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ greenwavereality==0.5.1 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.6.0 +growattServer==1.7.1 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbfcb8b2435..9a0a86ca37f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -961,7 +961,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.1 # homeassistant.components.growatt_server -growattServer==1.6.0 +growattServer==1.7.1 # homeassistant.components.google_sheets gspread==5.5.0 From f7eacaa48d504db58bb423e19a1e904bbaac2a26 Mon Sep 17 00:00:00 2001 From: "L." Date: Thu, 31 Jul 2025 09:01:06 +0200 Subject: [PATCH 0795/1118] Bump xiaomi-ble to 1.2.0 (#149711) --- 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 2897fbbdb16..bd318c5e30b 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==1.1.0"] + "requirements": ["xiaomi-ble==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3c3e421f5e9..1e20bfcd6c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3139,7 +3139,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.1.0 +xiaomi-ble==1.2.0 # homeassistant.components.knx xknx==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a0a86ca37f..b4d412335a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2592,7 +2592,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==1.1.0 +xiaomi-ble==1.2.0 # homeassistant.components.knx xknx==3.8.0 From 42101dd432cac23c67c68ba98e051c3b137934be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Jul 2025 10:58:36 +0200 Subject: [PATCH 0796/1118] Remove result from FlowResult (#149202) --- homeassistant/auth/models.py | 5 ++++- homeassistant/components/auth/login_flow.py | 5 +++-- homeassistant/components/config/config_entries.py | 5 +++-- homeassistant/components/repairs/issue_handler.py | 2 -- homeassistant/config_entries.py | 4 ++-- homeassistant/data_entry_flow.py | 1 - homeassistant/helpers/data_entry_flow.py | 2 +- tests/components/cloud/test_repairs.py | 1 - .../generic_hygrostat/snapshots/test_config_flow.ambr | 1 - .../generic_thermostat/snapshots/test_config_flow.ambr | 1 - tests/components/hassio/test_repairs.py | 1 - .../components/homeassistant_sky_connect/test_config_flow.py | 1 - tests/components/homeassistant_yellow/test_config_flow.py | 1 - tests/components/jewish_calendar/test_config_flow.py | 1 - tests/components/repairs/test_websocket_api.py | 1 - 15 files changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 7dcccbb1a1e..f92ed38ad85 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -33,7 +33,10 @@ class AuthFlowContext(FlowContext, total=False): redirect_uri: str -AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]] +class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False): + """Typed result dict for auth flow.""" + + result: Credentials # Only present if type is CREATE_ENTRY @attr.s(slots=True) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index d27235123b9..fe7ccededf2 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -268,7 +268,7 @@ class LoginFlowBaseView(HomeAssistantView): result.pop("data") result.pop("context") - result_obj: Credentials = result.pop("result") + result_obj = result.pop("result") # Result can be None if credential was never linked to a user before. user = await hass.auth.async_get_user_by_credentials(result_obj) @@ -281,7 +281,8 @@ class LoginFlowBaseView(HomeAssistantView): ) process_success_login(request) - result["result"] = self._store_result(client_id, result_obj) + # We overwrite the Credentials object with the string code to retrieve it. + result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item] return self.json(result) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d20d4de881f..a9aafcfaa5e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -146,8 +146,9 @@ def _prepare_config_flow_result_json( return prepare_result_json(result) data = result.copy() - entry: config_entries.ConfigEntry = data["result"] - data["result"] = entry.as_json_fragment + entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item] + # We overwrite the ConfigEntry object with its json representation. + data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key] data.pop("data") data.pop("context") return data diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index cc7e017699d..63da15b1ede 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -89,8 +89,6 @@ class RepairsFlowManager(data_entry_flow.FlowManager): """ if result.get("type") != data_entry_flow.FlowResultType.ABORT: ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) - if "result" not in result: - result["result"] = None return result diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1c4f2b51ac7..da8e73d9566 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -298,8 +298,10 @@ class ConfigFlowContext(FlowContext, total=False): class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): """Typed result dict for config flow.""" + # Extra keys, only present if type is CREATE_ENTRY minor_version: int options: Mapping[str, Any] + result: ConfigEntry subentries: Iterable[ConfigSubentryData] version: int @@ -3345,7 +3347,6 @@ class ConfigSubentryFlowManager( ), ) - result["result"] = True return result @@ -3508,7 +3509,6 @@ class OptionsFlowManager( ): self.hass.config_entries.async_schedule_reload(entry.entry_id) - result["result"] = True return result async def _async_setup_preview( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index ce1c0806b14..6b2f9a4dc5c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -142,7 +142,6 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False): progress_task: asyncio.Task[Any] | None reason: str required: bool - result: Any step_id: str title: str translation_domain: str diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 65eb2786aaf..22074fb90a7 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -35,7 +35,7 @@ class _BaseFlowManagerView(HomeAssistantView, Generic[_FlowManagerT]): """Convert result to JSON.""" if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() - data.pop("result") + assert "result" not in result data.pop("data") data.pop("context") return data diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d131d211e2f..bb3c874c077 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -236,7 +236,6 @@ async def test_legacy_subscription_repair_flow_timeout( "handler": "cloud", "reason": "operation_took_too_long", "description_placeholders": None, - "result": None, } assert issue_registry.async_get_issue( diff --git a/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr index 3527596c9b9..859c0eeb1fe 100644 --- a/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr +++ b/tests/components/generic_hygrostat/snapshots/test_config_flow.ambr @@ -15,7 +15,6 @@ # --- # name: test_options[create_entry] FlowResultSnapshot({ - 'result': True, 'type': , }) # --- diff --git a/tests/components/generic_thermostat/snapshots/test_config_flow.ambr b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr index ed757d1c2ae..e69e51e19cd 100644 --- a/tests/components/generic_thermostat/snapshots/test_config_flow.ambr +++ b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr @@ -39,7 +39,6 @@ # --- # name: test_options[create_entry] FlowResultSnapshot({ - 'result': True, 'type': , }) # --- diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 4c4f0e24dcc..39f9d4580bd 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -471,7 +471,6 @@ async def test_mount_failed_repair_flow_error( "flow_id": flow_id, "handler": "hassio", "reason": "apply_suggestion_fail", - "result": None, "description_placeholders": None, } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 4df3efab360..bdde5e09ea6 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -211,7 +211,6 @@ async def test_options_flow( ) assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert create_result["result"] is True assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index d5f1c380971..6e2120aa961 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -406,7 +406,6 @@ async def test_firmware_options_flow( ) assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert create_result["result"] is True assert config_entry.data == { "firmware": fw_type.value, diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index a63d9abb9a7..234cae2adca 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -110,7 +110,6 @@ async def test_options_reconfigure( CONF_CANDLE_LIGHT_MINUTES: DEFAULT_CANDLE_LIGHT + 1, }, ) - assert result["result"] # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index bbaf70e0a9b..1474e90c8ea 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -599,7 +599,6 @@ async def test_fix_issue_aborted( "handler": "fake_integration", "reason": "not_given", "description_placeholders": None, - "result": None, } await ws_client.send_json({"id": 4, "type": "repairs/list_issues"}) From 3952544822a3f4d77cd4f66fd022267d830d5640 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:06:04 +0200 Subject: [PATCH 0797/1118] Fix ContextVar deprecation warning in homeassistant_hardware integration (#149687) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com> Co-authored-by: mib1185 <35783820+mib1185@users.noreply.github.com> --- .../components/homeassistant_hardware/coordinator.py | 10 +++++++++- .../components/homeassistant_sky_connect/update.py | 1 + .../components/homeassistant_yellow/update.py | 1 + .../homeassistant_hardware/test_coordinator.py | 6 +++++- tests/components/homeassistant_hardware/test_update.py | 2 ++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py index c9a5c891328..36a2f407282 100644 --- a/homeassistant/components/homeassistant_hardware/coordinator.py +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -12,6 +12,7 @@ from ha_silabs_firmware_client import ( ManifestMissing, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,13 +25,20 @@ FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8) class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): """Coordinator to manage firmware updates.""" - def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + session: ClientSession, + url: str, + ) -> None: """Initialize the firmware update coordinator.""" super().__init__( hass, _LOGGER, name="firmware update coordinator", update_interval=FIRMWARE_REFRESH_INTERVAL, + config_entry=config_entry, ) self.hass = hass self.session = session diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py index 74c28b37eaf..df69b6d40a2 100644 --- a/homeassistant/components/homeassistant_sky_connect/update.py +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -124,6 +124,7 @@ def _async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, NABU_CASA_FIRMWARE_RELEASES_URL, ), diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py index 9531bd456cb..7a6e2f19b1f 100644 --- a/homeassistant/components/homeassistant_yellow/update.py +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -129,6 +129,7 @@ def _async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, NABU_CASA_FIRMWARE_RELEASES_URL, ), diff --git a/tests/components/homeassistant_hardware/test_coordinator.py b/tests/components/homeassistant_hardware/test_coordinator.py index 9c57aac6811..39fef3366ad 100644 --- a/tests/components/homeassistant_hardware/test_coordinator.py +++ b/tests/components/homeassistant_hardware/test_coordinator.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry + async def test_firmware_update_coordinator_fetching( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -20,6 +22,8 @@ async def test_firmware_update_coordinator_fetching( """Test the firmware update coordinator loads manifests.""" session = async_get_clientsession(hass) + mock_config_entry = MockConfigEntry() + manifest = FirmwareManifest( url=URL("https://example.org/firmware"), html_url=URL("https://example.org/release_notes"), @@ -35,7 +39,7 @@ async def test_firmware_update_coordinator_fetching( return_value=mock_client, ): coordinator = FirmwareUpdateCoordinator( - hass, session, "https://example.org/firmware" + hass, mock_config_entry, session, "https://example.org/firmware" ) listener = Mock() diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index aacc064e4f2..3103e5cfc6a 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -143,6 +143,7 @@ def _mock_async_create_update_entity( config_entry=config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + config_entry, session, TEST_FIRMWARE_RELEASES_URL, ), @@ -593,6 +594,7 @@ async def test_update_entity_graceful_firmware_type_callback_errors( config_entry=update_config_entry, update_coordinator=FirmwareUpdateCoordinator( hass, + update_config_entry, session, TEST_FIRMWARE_RELEASES_URL, ), From f7c8cdb3a78b2484efb495df1a445617476bc3c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 00:10:23 -1000 Subject: [PATCH 0798/1118] Bump aioesphomeapi to 37.2.0 (#149732) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 355089555c5..5a7c9a5f927 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.1.6", + "aioesphomeapi==37.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 1e20bfcd6c2..49a50982f32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.6 +aioesphomeapi==37.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4d412335a6..8b992f2630c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.1.6 +aioesphomeapi==37.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 3d744f032fb1b9042f027d3fd544b64e543a2354 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Jul 2025 12:35:13 +0200 Subject: [PATCH 0799/1118] Make _EventDeviceRegistryUpdatedData_Remove JSON serializable (#149734) --- homeassistant/helpers/device_registry.py | 4 ++-- homeassistant/helpers/entity_registry.py | 6 +++--- tests/helpers/test_device_registry.py | 20 ++++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bc6e7c810bf..c8b4428a7cc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -156,7 +156,7 @@ class _EventDeviceRegistryUpdatedData_Remove(TypedDict): action: Literal["remove"] device_id: str - device: DeviceEntry + device: dict[str, Any] class _EventDeviceRegistryUpdatedData_Update(TypedDict): @@ -1319,7 +1319,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, _EventDeviceRegistryUpdatedData_Remove( - action="remove", device_id=device_id, device=device + action="remove", device_id=device_id, device=device.dict_repr ), ) self.async_schedule_save() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 7051521b805..d972b421fc4 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1103,13 +1103,13 @@ class EntityRegistry(BaseRegistry): entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) - removed_device = event.data["device"] + removed_device_dict = event.data["device"] for entity in entities: config_entry_id = entity.config_entry_id if ( - config_entry_id in removed_device.config_entries + config_entry_id in removed_device_dict["config_entries"] and entity.config_subentry_id - in removed_device.config_entries_subentries[config_entry_id] + in removed_device_dict["config_entries_subentries"][config_entry_id] ): self.async_remove(entity.entity_id) else: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 23a451dd06c..a66684c94e3 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1652,7 +1652,7 @@ async def test_removing_config_entries( assert update_events[4].data == { "action": "remove", "device_id": entry3.id, - "device": entry3, + "device": entry3.dict_repr, } @@ -1725,12 +1725,12 @@ async def test_deleted_device_removing_config_entries( assert update_events[3].data == { "action": "remove", "device_id": entry.id, - "device": entry2, + "device": entry2.dict_repr, } assert update_events[4].data == { "action": "remove", "device_id": entry3.id, - "device": entry3, + "device": entry3.dict_repr, } device_registry.async_clear_config_entry(config_entry_1.entry_id) @@ -1976,7 +1976,7 @@ async def test_removing_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry.id, - "device": entry, + "device": entry.dict_repr, } @@ -2106,7 +2106,7 @@ async def test_deleted_device_removing_config_subentries( assert update_events[4].data == { "action": "remove", "device_id": entry.id, - "device": entry4, + "device": entry4.dict_repr, } device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) @@ -2930,7 +2930,7 @@ async def test_update_remove_config_entries( assert update_events[6].data == { "action": "remove", "device_id": entry3.id, - "device": entry3, + "device": entry3.dict_repr, } @@ -3208,7 +3208,7 @@ async def test_update_remove_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry_id, - "device": entry_before_remove, + "device": entry_before_remove.dict_repr, } @@ -3551,7 +3551,7 @@ async def test_restore_device( assert update_events[2].data == { "action": "remove", "device_id": entry.id, - "device": entry, + "device": entry.dict_repr, } assert update_events[3].data == { "action": "create", @@ -3874,7 +3874,7 @@ async def test_restore_shared_device( assert update_events[3].data == { "action": "remove", "device_id": entry.id, - "device": updated_device, + "device": updated_device.dict_repr, } assert update_events[4].data == { "action": "create", @@ -3883,7 +3883,7 @@ async def test_restore_shared_device( assert update_events[5].data == { "action": "remove", "device_id": entry.id, - "device": entry2, + "device": entry2.dict_repr, } assert update_events[6].data == { "action": "create", From 04fb86b4ba027ce68ccdd3d079151744c0a71057 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:19:37 -0400 Subject: [PATCH 0800/1118] Fix unique_id in config validation for legacy weather platform (#149742) --- homeassistant/components/template/weather.py | 2 ++ tests/components/template/test_weather.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 7f79adc2201..bddb55197c3 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -34,6 +34,7 @@ from homeassistant.components.weather import ( from homeassistant.const import ( CONF_NAME, CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -151,6 +152,7 @@ PLATFORM_SCHEMA = vol.Schema( vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 6e2a2ab2f6b..7eac7ff28aa 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -132,6 +132,7 @@ async def setup_weather( { "platform": "template", "name": "test", + "unique_id": "abc123", "attribution_template": "{{ states('sensor.attribution') }}", "condition_template": "sunny", "temperature_template": "{{ states('sensor.temperature') | float }}", From 59d8df142db4142d3e2407018b970ef57defeffc Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:19:43 -0400 Subject: [PATCH 0801/1118] Nitpick default translations for template integration (#149740) --- homeassistant/components/template/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index b412fa519cd..d29bfbeb3fb 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -39,7 +39,7 @@ "arm_vacation": "Defines actions to run when the alarm control panel is armed to `arm_vacation`. Receives variable `code`.", "trigger": "Defines actions to run when the alarm control panel is triggered. Receives variable `code`.", "code_arm_required": "If true, the code is required to arm the alarm.", - "code_format": "One of number, text or no_code. Format for the code used to arm/disarm the alarm." + "code_format": "One of `number`, `text` or `no_code`. Format for the code used to arm/disarm the alarm." }, "sections": { "advanced_options": { @@ -179,7 +179,7 @@ "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", "url": "Defines a template to get the URL on which the image is served.", - "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http-only URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." + "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." }, "sections": { "advanced_options": { @@ -282,7 +282,7 @@ "set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.", "max": "Template for the number's maximum value.", "min": "Template for the number's minimum value.", - "unit_of_measurement": "Defines the units of measurement of the number, if any." + "unit_of_measurement": "Defines the unit of measurement of the number, if any." }, "sections": { "advanced_options": { @@ -336,7 +336,7 @@ "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", "state": "Defines a template to get the state of the sensor. If the sensor is numeric, i.e. it has a `state_class` or a `unit_of_measurement`, the state template must render to a number or to `none`. The state template must not render to a string, including `unknown` or `unavailable`. An `availability` template may be defined to suppress rendering of the state template.", - "unit_of_measurement": "Defines the units of measurement of the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." + "unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." }, "sections": { "advanced_options": { @@ -418,7 +418,7 @@ "start": "Defines actions to run when the vacuum is started.", "fan_speed": "Defines a template to get the fan speed of the vacuum.", "fan_speeds": "List of fan speeds supported by the vacuum.", - "set_fan_speed": "Defines actions to run when the vacuum is given a command to set the fan speed. Receives variable `fan_speed`", + "set_fan_speed": "Defines actions to run when the vacuum is given a command to set the fan speed. Receives variable `fan_speed`.", "stop": "Defines actions to run when the vacuum is stopped.", "pause": "Defines actions to run when the vacuum is paused.", "return_to_base": "Defines actions to run when the vacuum is given a 'Return to dock' command.", From 58dc6a952e7958221c08b033d48daf55b048cc29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:35:55 +0200 Subject: [PATCH 0802/1118] Bump home-assistant/wheels from 2025.03.0 to 2025.07.0 (#149741) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ea02b249dc9..8d9fca093de 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.03.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.03.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 5f6b1212a31fad66ee4ffd9acc6cb6cc457bd994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 31 Jul 2025 15:04:09 +0100 Subject: [PATCH 0803/1118] Remove data flow step_id deprecation note (#149714) --- homeassistant/data_entry_flow.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6b2f9a4dc5c..7408993cc47 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -705,10 +705,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): last_step: bool | None = None, preview: str | None = None, ) -> _FlowResultT: - """Return the definition of a form to gather user input. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Return the definition of a form to gather user input.""" flow_result = self._flow_result( type=FlowResultType.FORM, flow_id=self.flow_id, @@ -770,10 +767,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): url: str, description_placeholders: Mapping[str, str] | None = None, ) -> _FlowResultT: - """Return the definition of an external step for the user to take. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Return the definition of an external step for the user to take.""" flow_result = self._flow_result( type=FlowResultType.EXTERNAL_STEP, flow_id=self.flow_id, @@ -804,10 +798,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders: Mapping[str, str] | None = None, progress_task: asyncio.Task[Any] | None = None, ) -> _FlowResultT: - """Show a progress message to the user, without user input allowed. - - The step_id parameter is deprecated and will be removed in a future release. - """ + """Show a progress message to the user, without user input allowed.""" if progress_task is None and not self.__no_progress_task_reported: self.__no_progress_task_reported = True cls = self.__class__ @@ -867,7 +858,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Show a navigation menu to the user. Options dict maps step_id => i18n label - The step_id parameter is deprecated and will be removed in a future release. """ flow_result = self._flow_result( type=FlowResultType.MENU, From 6ad1b8dcb11b3997e0526a3d1a0b65783843f6c4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Jul 2025 17:49:09 +0200 Subject: [PATCH 0804/1118] Fix kitchen_sink option flow (#149760) --- homeassistant/components/kitchen_sink/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 059fd11999f..056ace7011c 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -99,7 +99,7 @@ class OptionsFlowHandler(OptionsFlowWithReload): ), } ) - self.add_suggested_values_to_schema( + data_schema = self.add_suggested_values_to_schema( data_schema, {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}}, ) From f7d54b46ecffcdcbd6b745bba3b932d038b9f88b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Jul 2025 17:55:15 +0200 Subject: [PATCH 0805/1118] Improve test of FlowHandler.add_suggested_values_to_schema (#149759) --- tests/test_data_entry_flow.py | 42 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index a5908f0feab..fc40a330a1a 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -155,19 +155,23 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) async def async_step_init(self, user_input=None): data_schema = self.add_suggested_values_to_schema( schema, - { - "username": "doej", - "password": "verySecret1", - "section_1": {"full_name": "John Doe"}, - }, + user_input, ) return self.async_show_form( step_id="init", data_schema=data_schema, ) - form = await manager.async_init("test") + form = await manager.async_init( + "test", + data={ + "username": "doej", + "password": "verySecret1", + "section_1": {"full_name": "John Doe"}, + }, + ) assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema is not schema.schema assert form["data_schema"].schema == schema.schema markers = list(form["data_schema"].schema) assert len(markers) == 3 @@ -187,6 +191,32 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) assert section_markers[0] == "full_name" assert section_markers[0].description == {"suggested_value": "John Doe"} + # Test again without suggested values to make sure we're not mutating the schema + form = await manager.async_init( + "test", + ) + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema is not schema.schema + assert form["data_schema"].schema == schema.schema + markers = list(form["data_schema"].schema) + assert len(markers) == 3 + assert markers[0] == "username" + assert markers[0].description is None + assert markers[1] == "password" + assert markers[1].description is None + assert markers[2] == "section_1" + section_validator = form["data_schema"].schema["section_1"] + assert isinstance(section_validator, data_entry_flow.section) + # The section class was not replaced + assert section_validator is schema.schema["section_1"] + # The section schema was not replaced + assert section_validator.schema is schema.schema["section_1"].schema + section_markers = list(section_validator.schema.schema) + assert len(section_markers) == 1 + assert section_markers[0] == "full_name" + # This is a known bug, which needs to be fixed + assert section_markers[0].description == {"suggested_value": "John Doe"} + async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" From 21a97990604d703939aff51f928208dbbbabbce1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 31 Jul 2025 18:46:10 +0200 Subject: [PATCH 0806/1118] Update frontend to 20250731.0 (#149757) --- 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 09461a3543a..706940f5da7 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==20250730.0"] + "requirements": ["home-assistant-frontend==20250731.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 704fb282784..cd0fc31b008 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.110.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250730.0 +home-assistant-frontend==20250731.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49a50982f32..1bbc5a72267 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250730.0 +home-assistant-frontend==20250731.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b992f2630c..51ccb301d1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250730.0 +home-assistant-frontend==20250731.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From bbc1466cfc6a899e322ef61a1f62135b4ed3d75b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:51:10 +0200 Subject: [PATCH 0807/1118] Update rpds-py to 0.26.0 (#149753) --- homeassistant/package_constraints.txt | 5 ++--- script/gen_requirements_all.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd0fc31b008..ac91084c4f1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -209,7 +209,6 @@ aiofiles>=24.1.0 # https://github.com/aio-libs/multidict/issues/1131 multidict>=6.4.2 -# rpds-py > 0.25.0 requires cargo 1.84.0 -# Stable Alpine current only ships cargo 1.83.0 +# rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 -rpds-py==0.24.0 +rpds-py==0.26.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 13bb3384258..b13f586439d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -235,10 +235,9 @@ aiofiles>=24.1.0 # https://github.com/aio-libs/multidict/issues/1131 multidict>=6.4.2 -# rpds-py > 0.25.0 requires cargo 1.84.0 -# Stable Alpine current only ships cargo 1.83.0 +# rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 -rpds-py==0.24.0 +rpds-py==0.26.0 """ GENERATED_MESSAGE = ( From aa6b37bc7c5baa9d8970baeaa5b51f96cd7fa17f Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:50:26 -0700 Subject: [PATCH 0808/1118] Fix `add_suggested_values_to_schema` when the schema has sections (#149718) Co-authored-by: Erik Montnemery --- homeassistant/data_entry_flow.py | 7 ++++--- tests/test_data_entry_flow.py | 32 +++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 7408993cc47..5023d291ad5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -676,9 +676,10 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): and key in suggested_values ): new_section_key = copy.copy(key) - schema[new_section_key] = val - val.schema = self.add_suggested_values_to_schema( - val.schema, suggested_values[key] + new_val = copy.copy(val) + schema[new_section_key] = new_val + new_val.schema = self.add_suggested_values_to_schema( + new_val.schema, suggested_values[key] ) continue diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index fc40a330a1a..0faa4dd1a80 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -135,6 +135,19 @@ async def test_show_form(manager: MockFlowManager) -> None: async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) -> None: """Test that we can show a form with suggested values.""" + + def compare_schemas(schema: vol.Schema, expected_schema: vol.Schema) -> None: + """Compare two schemas.""" + assert schema.schema is not expected_schema.schema + + assert list(schema.schema) == list(expected_schema.schema) + + for key, validator in schema.schema.items(): + if isinstance(validator, data_entry_flow.section): + assert validator.schema == expected_schema.schema[key].schema + continue + assert validator == expected_schema.schema[key] + schema = vol.Schema( { vol.Required("username"): str, @@ -172,7 +185,8 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) ) assert form["type"] == data_entry_flow.FlowResultType.FORM assert form["data_schema"].schema is not schema.schema - assert form["data_schema"].schema == schema.schema + assert form["data_schema"].schema != schema.schema + compare_schemas(form["data_schema"], schema) markers = list(form["data_schema"].schema) assert len(markers) == 3 assert markers[0] == "username" @@ -182,10 +196,11 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) assert markers[2] == "section_1" section_validator = form["data_schema"].schema["section_1"] assert isinstance(section_validator, data_entry_flow.section) - # The section class was not replaced - assert section_validator is schema.schema["section_1"] - # The section schema was not replaced - assert section_validator.schema is schema.schema["section_1"].schema + # The section instance was copied + assert section_validator is not schema.schema["section_1"] + # The section schema instance was copied + assert section_validator.schema is not schema.schema["section_1"].schema + assert section_validator.schema == schema.schema["section_1"].schema section_markers = list(section_validator.schema.schema) assert len(section_markers) == 1 assert section_markers[0] == "full_name" @@ -207,15 +222,14 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) assert markers[2] == "section_1" section_validator = form["data_schema"].schema["section_1"] assert isinstance(section_validator, data_entry_flow.section) - # The section class was not replaced + # The section class is not replaced if there is no suggested value for the section assert section_validator is schema.schema["section_1"] - # The section schema was not replaced + # The section schema is not replaced if there is no suggested value for the section assert section_validator.schema is schema.schema["section_1"].schema section_markers = list(section_validator.schema.schema) assert len(section_markers) == 1 assert section_markers[0] == "full_name" - # This is a known bug, which needs to be fixed - assert section_markers[0].description == {"suggested_value": "John Doe"} + assert section_markers[0].description is None async def test_abort_removes_instance(manager: MockFlowManager) -> None: From 21c1427abf64d338cf3295ad027b82d6207f2d62 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 14:52:17 -0400 Subject: [PATCH 0809/1118] Fix ZHA ContextVar deprecation by passing config_entry (#149748) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com> Co-authored-by: puddly <32534428+puddly@users.noreply.github.com> Co-authored-by: TheJulianJES <6409465+TheJulianJES@users.noreply.github.com> --- homeassistant/components/zha/update.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 062581fd259..867e4ff2dd3 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -58,7 +58,7 @@ async def async_setup_entry( zha_data = get_zha_data(hass) if zha_data.update_coordinator is None: zha_data.update_coordinator = ZHAFirmwareUpdateCoordinator( - hass, get_zha_gateway(hass).application_controller + hass, config_entry, get_zha_gateway(hass).application_controller ) entities_to_create = zha_data.platforms[Platform.UPDATE] @@ -79,12 +79,16 @@ class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disa """Firmware update coordinator that broadcasts updates network-wide.""" def __init__( - self, hass: HomeAssistant, controller_application: ControllerApplication + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + controller_application: ControllerApplication, ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="ZHA firmware update coordinator", update_method=self.async_update_data, ) From 61ca42e92314d077b699ea02019cc59a563ba412 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 31 Jul 2025 13:04:23 -0600 Subject: [PATCH 0810/1118] Bump pylitterbot to 2024.2.3 (#149763) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 33addd85ba2..e67c681ac53 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.2.2"] + "requirements": ["pylitterbot==2024.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1bbc5a72267..446d7117588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2122,7 +2122,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.2 +pylitterbot==2024.2.3 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51ccb301d1d..a4bf8743a52 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1767,7 +1767,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.2 +pylitterbot==2024.2.3 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 From 4b5fe424ede41404b17d333e2616e4f9a3004a91 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 1 Aug 2025 00:07:56 +0200 Subject: [PATCH 0811/1118] Hide configuration URL when Uptime Kuma is installed locally (#149781) --- homeassistant/components/uptime_kuma/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py index c76fbcae04c..b499c67da16 100644 --- a/homeassistant/components/uptime_kuma/sensor.py +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -162,7 +162,11 @@ class UptimeKumaSensorEntity( name=coordinator.data[monitor].monitor_name, identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, manufacturer="Uptime Kuma", - configuration_url=coordinator.config_entry.data[CONF_URL], + configuration_url=( + None + if "127.0.0.1" in (url := coordinator.config_entry.data[CONF_URL]) + else url + ), sw_version=coordinator.api.version.version, ) From eb222f6c5d84d810e8c880b9b89a075e6396bafa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Aug 2025 00:09:20 +0200 Subject: [PATCH 0812/1118] Bump motionblinds to 0.6.30 (#149764) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index eca520d8946..ac5390f5c64 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.29"] + "requirements": ["motionblinds==0.6.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 446d7117588..ef631e829fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1458,7 +1458,7 @@ monzopy==1.5.1 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.29 +motionblinds==0.6.30 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4bf8743a52..e8fc75a6273 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1250,7 +1250,7 @@ monzopy==1.5.1 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.29 +motionblinds==0.6.30 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From b86b0c10bd92ed4770cc034a68c88a8de4655017 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Jul 2025 12:23:24 -1000 Subject: [PATCH 0813/1118] Bump aioesphomeapi to 37.2.2 (#149755) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a7c9a5f927..6bf164aa9bc 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==37.2.0", + "aioesphomeapi==37.2.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index ef631e829fe..baad7de1409 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.2.0 +aioesphomeapi==37.2.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8fc75a6273..93709fec4b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==37.2.0 +aioesphomeapi==37.2.2 # homeassistant.components.flo aioflo==2021.11.0 From c72c600de49097e701db879108674e270bb9b7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Lafoucri=C3=A8re?= <12752+gravis@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:47:25 -0400 Subject: [PATCH 0814/1118] Fix bootstrap script path resolution (#149721) --- script/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/bootstrap b/script/bootstrap index e60342563ac..725cb856bbf 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,7 +4,7 @@ # Stop on errors set -e -cd "$(dirname "$0")/.." +cd "$(realpath "$(dirname "$0")/..")" echo "Installing development dependencies..." uv pip install wheel --constraint homeassistant/package_constraints.txt --upgrade From 61396d92a504eac53e231ca16cbd381a5bcc3378 Mon Sep 17 00:00:00 2001 From: Fabian Leutgeb Date: Fri, 1 Aug 2025 03:21:48 +0200 Subject: [PATCH 0815/1118] Homekit valve duration characteristics (#149698) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 4 +- homeassistant/components/homekit/const.py | 4 + .../components/homekit/type_switches.py | 108 ++++++++- homeassistant/components/homekit/util.py | 16 +- .../components/homekit/test_type_switches.py | 226 ++++++++++++++++++ tests/components/homekit/test_util.py | 44 ++++ 6 files changed, 398 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 95842d56094..681ebcbbef7 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -628,12 +628,12 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, domain: str, service: str, - service_data: dict[str, Any] | None, + service_data: dict[str, Any], value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" event_data = { - ATTR_ENTITY_ID: self.entity_id, + ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, ATTR_SERVICE: service, ATTR_VALUE: value, diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 44f18c30099..2d4e2b03079 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -57,6 +57,8 @@ CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" +CONF_LINKED_VALVE_DURATION = "linked_valve_duration" +CONF_LINKED_VALVE_END_TIME = "linked_valve_end_time" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -229,10 +231,12 @@ CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" +CHAR_REMAINING_DURATION = "RemainingDuration" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" CHAR_SATURATION = "Saturation" +CHAR_SET_DURATION = "SetDuration" CHAR_SERIAL_NUMBER = "SerialNumber" CHAR_SERVICE_LABEL_INDEX = "ServiceLabelIndex" CHAR_SERVICE_LABEL_NAMESPACE = "ServiceLabelNamespace" diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 18150c820c3..c011b8cd327 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -15,6 +15,11 @@ from pyhap.const import ( ) from homeassistant.components import button, input_button +from homeassistant.components.input_number import ( + ATTR_VALUE as INPUT_NUMBER_ATTR_VALUE, + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, @@ -45,6 +50,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( @@ -54,7 +60,11 @@ from .const import ( CHAR_NAME, CHAR_ON, CHAR_OUTLET_IN_USE, + CHAR_REMAINING_DURATION, + CHAR_SET_DURATION, CHAR_VALVE_TYPE, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, SERV_OUTLET, SERV_SWITCH, SERV_VALVE, @@ -271,7 +281,21 @@ class ValveBase(HomeAccessory): self.on_service = on_service self.off_service = off_service - serv_valve = self.add_preload_service(SERV_VALVE) + self.chars = [] + + self.linked_duration_entity: str | None = self.config.get( + CONF_LINKED_VALVE_DURATION + ) + self.linked_end_time_entity: str | None = self.config.get( + CONF_LINKED_VALVE_END_TIME + ) + + if self.linked_duration_entity: + self.chars.append(CHAR_SET_DURATION) + if self.linked_end_time_entity: + self.chars.append(CHAR_REMAINING_DURATION) + + serv_valve = self.add_preload_service(SERV_VALVE, self.chars) self.char_active = serv_valve.configure_char( CHAR_ACTIVE, value=False, setter_callback=self.set_state ) @@ -279,6 +303,25 @@ class ValveBase(HomeAccessory): self.char_valve_type = serv_valve.configure_char( CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type].valve_type ) + + if CHAR_SET_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_SET_DURATION + ) + self.char_set_duration = serv_valve.configure_char( + CHAR_SET_DURATION, + value=self.get_duration(), + setter_callback=self.set_duration, + ) + + if CHAR_REMAINING_DURATION in self.chars: + _LOGGER.debug( + "%s: Add characteristic %s", self.entity_id, CHAR_REMAINING_DURATION + ) + self.char_remaining_duration = serv_valve.configure_char( + CHAR_REMAINING_DURATION, getter_callback=self.get_remaining_duration + ) + # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup self.async_update_state(state) @@ -294,12 +337,75 @@ class ValveBase(HomeAccessory): @callback def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" + self._update_duration_chars() current_state = 1 if new_state.state in self.open_states else 0 _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) self.char_active.set_value(current_state) _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, current_state) self.char_in_use.set_value(current_state) + def _update_duration_chars(self) -> None: + """Update valve duration related properties if characteristics are available.""" + if CHAR_SET_DURATION in self.chars: + self.char_set_duration.set_value(self.get_duration()) + if CHAR_REMAINING_DURATION in self.chars: + self.char_remaining_duration.set_value(self.get_remaining_duration()) + + def set_duration(self, value: int) -> None: + """Set default duration for how long the valve should remain open.""" + _LOGGER.debug("%s: Set default run time to %s", self.entity_id, value) + self.async_call_service( + INPUT_NUMBER_DOMAIN, + INPUT_NUMBER_SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: self.linked_duration_entity, + INPUT_NUMBER_ATTR_VALUE: value, + }, + value, + ) + + def get_duration(self) -> int: + """Get the default duration from Home Assistant.""" + duration_state = self._get_entity_state(self.linked_duration_entity) + if duration_state is None: + _LOGGER.debug( + "%s: No linked duration entity state available", self.entity_id + ) + return 0 + + try: + duration = float(duration_state) + return max(int(duration), 0) + except ValueError: + _LOGGER.debug("%s: Cannot parse linked duration entity", self.entity_id) + return 0 + + def get_remaining_duration(self) -> int: + """Calculate the remaining duration based on end time in Home Assistant.""" + end_time_state = self._get_entity_state(self.linked_end_time_entity) + if end_time_state is None: + _LOGGER.debug( + "%s: No linked end time entity state available", self.entity_id + ) + return self.get_duration() + + end_time = dt_util.parse_datetime(end_time_state) + if end_time is None: + _LOGGER.debug("%s: Cannot parse linked end time entity", self.entity_id) + return self.get_duration() + + remaining_time = (end_time - dt_util.utcnow()).total_seconds() + return max(int(remaining_time), 0) + + def _get_entity_state(self, entity_id: str | None) -> str | None: + """Fetch the state of a linked entity.""" + if entity_id is None: + return None + state = self.hass.states.get(entity_id) + if state is None: + return None + return state.state + @TYPES.register("ValveSwitch") class ValveSwitch(ValveBase): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 85207e09626..ea67e30a3c1 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.components import ( binary_sensor, + input_number, media_player, persistent_notification, sensor, @@ -69,6 +70,8 @@ from .const import ( CONF_LINKED_OBSTRUCTION_SENSOR, CONF_LINKED_PM25_SENSOR, CONF_LINKED_TEMPERATURE_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -266,7 +269,9 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( TYPE_VALVE, ) ), - ) + ), + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), } ) @@ -277,6 +282,12 @@ SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +VALVE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_LINKED_VALVE_DURATION): cv.entity_domain(input_number.DOMAIN), + vol.Optional(CONF_LINKED_VALVE_END_TIME): cv.entity_domain(sensor.DOMAIN), + } +) HOMEKIT_CHAR_TRANSLATIONS = { 0: " ", # nul @@ -360,6 +371,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "sensor": config = SENSOR_SCHEMA(config) + elif domain == "valve": + config = VALVE_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 3f0f0a3c22b..47a9c398d16 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun import freeze_time import pytest from homeassistant.components.homekit.const import ( @@ -22,6 +23,10 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.input_number import ( + DOMAIN as INPUT_NUMBER_DOMAIN, + SERVICE_SET_VALUE as INPUT_NUMBER_SERVICE_SET_VALUE, +) from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, SERVICE_DOCK, @@ -30,6 +35,7 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntityFeature, ) from homeassistant.components.select import ATTR_OPTIONS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -658,3 +664,223 @@ async def test_button_switch( await hass.async_block_till_done() assert acc.char_on.value is False assert len(events) == 1 + + +async def test_valve_switch_with_set_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "0") + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_duration": "input_number.valve_duration"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_duration() == 0 + + # Simulate setting duration from HomeKit + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + acc.char_set_duration.client_update_value(300) + await hass.async_block_till_done() + assert call_set_value + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": 300, + } + + # Assert state change in Home Assistant is synced to HomeKit + hass.states.async_set("input_number.valve_duration", "600") + await hass.async_block_till_done() + assert acc.get_duration() == 600 + + # Test fallback if no state is set + hass.states.async_remove("input_number.valve_duration") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test remaining duration fallback if no end time is linked + assert acc.get_remaining_duration() == 0 + + +async def test_valve_switch_with_remaining_duration_characteristic( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with remaining duration characteristic.""" + entity_id = "switch.sprinkler" + + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + {"type": "sprinkler", "linked_valve_end_time": "sensor.valve_end_time"}, + ) + acc.run() + await hass.async_block_till_done() + + # Assert initial state is synced + assert acc.get_remaining_duration() == 0 + + # Simulate remaining duration update from Home Assistant + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=90)).isoformat(), + ) + await hass.async_block_till_done() + + # Assert remaining duration is calculated correctly based on end time + assert acc.get_remaining_duration() == 90 + + # Test fallback if no state is set + hass.states.async_remove("sensor.valve_end_time") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get duration fallback if no duration is linked + assert acc.get_duration() == 0 + + +async def test_valve_switch_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve switch with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "300") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Mock switch services to prevent errors + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_ON) + async_mock_service(hass, SWITCH_DOMAIN, SERVICE_TURN_OFF) + # Mock input_number service for set_duration calls + call_set_value = async_mock_service( + hass, INPUT_NUMBER_DOMAIN, INPUT_NUMBER_SERVICE_SET_VALUE + ) + + acc = ValveSwitch( + hass, + hk_driver, + "Sprinkler", + entity_id, + 5, + { + "type": "sprinkler", + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + # Test update_duration_chars with both characteristics + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=60)).isoformat(), + ) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_set_duration.value == 300 + assert acc.get_remaining_duration() == 60 + + # Test get_duration fallback with invalid state + hass.states.async_set("input_number.valve_duration", "invalid") + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + # Test get_remaining_duration fallback with invalid state + hass.states.async_set("sensor.valve_end_time", "invalid") + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test get_remaining_duration with end time in the past + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() - timedelta(seconds=10)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_remaining_duration() == 0 + + # Test set_duration with negative value + acc.set_duration(-10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + # Verify the service was called with correct parameters + assert len(call_set_value) == 1 + assert call_set_value[0].data == { + "entity_id": "input_number.valve_duration", + "value": -10, + } + + # Test set_duration with negative state + hass.states.async_set("sensor.valve_duration", -10) + await hass.async_block_till_done() + assert acc.get_duration() == 0 + + +async def test_valve_with_duration_characteristics( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test valve with set duration and remaining duration characteristics.""" + entity_id = "switch.sprinkler" + + # Test with duration and end time entities linked + hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set("input_number.valve_duration", "900") + hass.states.async_set("sensor.valve_end_time", dt_util.utcnow().isoformat()) + await hass.async_block_till_done() + + # Using Valve instead of ValveSwitch + acc = Valve( + hass, + hk_driver, + "Valve", + entity_id, + 5, + { + "linked_valve_duration": "input_number.valve_duration", + "linked_valve_end_time": "sensor.valve_end_time", + }, + ) + acc.run() + await hass.async_block_till_done() + + with freeze_time(dt_util.utcnow()): + hass.states.async_set( + "sensor.valve_end_time", + (dt_util.utcnow() + timedelta(seconds=600)).isoformat(), + ) + await hass.async_block_till_done() + assert acc.get_duration() == 900 + assert acc.get_remaining_duration() == 600 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 66906c72266..4cb8eb41489 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -15,6 +15,8 @@ from homeassistant.components.homekit.const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_VALVE_DURATION, + CONF_LINKED_VALVE_END_TIME, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -128,7 +130,25 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + { + "switch.test": { + CONF_TYPE: "sprinkler", + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number entity + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + } + }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + { + "valve.test": { + CONF_LINKED_VALVE_END_TIME: "datetime.valve_end_time", # Must be sensor (timestamp) entity + CONF_LINKED_VALVE_DURATION: "number.valve_duration", # Must be input_number + } + }, + { + "valve.test": { + CONF_TYPE: "sprinkler", # Extra keys not allowed + } + }, ] for conf in configs: @@ -212,6 +232,19 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + config = { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"switch.sprinkler": config}) == { + "switch.sprinkler": { + CONF_TYPE: TYPE_SPRINKLER, + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } assert vec({"sensor.co": {CONF_THRESHOLD_CO: 500}}) == { "sensor.co": {CONF_THRESHOLD_CO: 500, CONF_LOW_BATTERY_THRESHOLD: 20} } @@ -244,6 +277,17 @@ def test_validate_entity_config() -> None: CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, } } + config = { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + } + assert vec({"valve.demo": config}) == { + "valve.demo": { + CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", + CONF_LINKED_VALVE_END_TIME: "sensor.valve_end_time", + CONF_LOW_BATTERY_THRESHOLD: DEFAULT_LOW_BATTERY_THRESHOLD, + } + } def test_validate_media_player_features() -> None: From 4d59e8cd800aec7007c73d551438504d7f364aac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Aug 2025 07:49:51 +0200 Subject: [PATCH 0816/1118] Fix flaky velbus test (#149743) --- tests/components/velbus/conftest.py | 5 +- .../velbus/snapshots/test_init.ambr | 152 ++++++++++++------ tests/components/velbus/test_init.py | 3 +- 3 files changed, 112 insertions(+), 48 deletions(-) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index f7cbeb7a052..d909480c8ea 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -97,6 +97,7 @@ def mock_module_subdevices() -> AsyncMock: """Mock a velbus module.""" module = AsyncMock(spec=Module) module.get_type_name.return_value = "VMB2BLE" + module.get_type.return_value = "123" module.get_addresses.return_value = [88] module.get_name.return_value = "Kitchen" module.get_serial.return_value = "a1b2c3d4e5f6" @@ -138,7 +139,7 @@ def mock_temperature() -> AsyncMock: channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_serial.return_value = "asdfghjk" channel.get_module_type.return_value = 1 - channel.is_sub_device.return_value = False + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = False channel.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -184,7 +185,7 @@ def mock_select() -> AsyncMock: channel.get_full_name.return_value = "Kitchen" channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_serial.return_value = "qwerty1234567" - channel.is_sub_device.return_value = False + channel.is_sub_device.return_value = True channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_selected_program.return_value = "winter" return channel diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 1e17753a02f..037ab7e6236 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -46,7 +46,38 @@ 'identifiers': set({ tuple( 'velbus', - '88-9', + '2', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB7IN', + 'model_id': '8', + 'name': 'Input', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88', ), }), 'is_new': False, @@ -54,13 +85,44 @@ }), 'manufacturer': 'Velleman', 'model': 'VMB2BLE', - 'model_id': '10', - 'name': 'Basement', + 'model_id': '123', + 'name': 'Kitchen (VMB2BLE)', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': '1234', + 'serial_number': 'a1b2c3d4e5f6', 'suggested_area': None, - 'sw_version': '1.0.1', + 'sw_version': '2.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-10', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMBDN1', + 'model_id': '9', + 'name': 'Dimmer full name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ @@ -94,37 +156,6 @@ 'sw_version': '1.0.1', 'via_device_id': , }), - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'velbus', - '88-10', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Velleman', - 'model': 'VMBDN1', - 'model_id': '9', - 'name': 'Dimmer full name', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': 'a1b2c3d4e5f6g7', - 'suggested_area': None, - 'sw_version': '1.0.0', - 'via_device_id': , - }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -170,7 +201,7 @@ 'identifiers': set({ tuple( 'velbus', - '88', + '88-3', ), }), 'is_new': False, @@ -185,7 +216,7 @@ 'serial_number': 'asdfghjk', 'suggested_area': None, 'sw_version': '3.0.0', - 'via_device_id': None, + 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -201,22 +232,22 @@ 'identifiers': set({ tuple( 'velbus', - '2', + '88-33', ), }), 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', - 'model': 'VMB7IN', - 'model_id': '8', - 'name': 'Input', + 'model': 'VMB4RYNO', + 'model_id': '3', + 'name': 'Kitchen', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': 'a1b2c3d4e5f6', + 'serial_number': 'qwerty1234567', 'suggested_area': None, - 'sw_version': '1.0.0', - 'via_device_id': None, + 'sw_version': '1.1.1', + 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -249,5 +280,36 @@ 'sw_version': '1.0.1', 'via_device_id': , }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'velbus', + '88-9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Velleman', + 'model': 'VMB2BLE', + 'model_id': '10', + 'name': 'Basement', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), ]) # --- diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 2d28ba81cb1..fc9046f977f 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -176,7 +176,8 @@ async def test_device_registry( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert device_entries == snapshot + # Sort by identifier to ensure consistent order in snapshot + assert sorted(device_entries, key=lambda x: list(x.identifiers)[0][1]) == snapshot device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")}) assert device_parent.via_device_id is None From 8b53b263338584f5e2246acaaaf56c65e1719410 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:13:53 +0200 Subject: [PATCH 0817/1118] Fix tuya light supported color modes (#149793) Co-authored-by: Erik --- homeassistant/components/tuya/light.py | 34 ++-- tests/components/tuya/__init__.py | 6 + .../tuya/fixtures/tyndj_pyakuuoc.json | 145 ++++++++++++++++++ .../components/tuya/snapshots/test_light.ambr | 56 +++++++ .../tuya/snapshots/test_sensor.ambr | 101 ++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++ 6 files changed, 377 insertions(+), 13 deletions(-) create mode 100644 tests/components/tuya/fixtures/tyndj_pyakuuoc.json diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index cb7555c38d8..7b73e825900 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityDescription, + color_supported, filter_supported_color_modes, ) from homeassistant.const import EntityCategory @@ -530,19 +531,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity): description.brightness_min, dptype=DPType.INTEGER ) - if int_type := self.find_dpcode( - description.color_temp, dptype=DPType.INTEGER, prefer_function=True - ): - self._color_temp = int_type - color_modes.add(ColorMode.COLOR_TEMP) - # If entity does not have color_temp, check if it has work_mode "white" - elif color_mode_enum := self.find_dpcode( - description.color_mode, dptype=DPType.ENUM, prefer_function=True - ): - if WorkMode.WHITE.value in color_mode_enum.range: - color_modes.add(ColorMode.WHITE) - self._white_color_mode = ColorMode.WHITE - if ( dpcode := self.find_dpcode(description.color_data, prefer_function=True) ) and self.get_dptype(dpcode) == DPType.JSON: @@ -568,6 +556,26 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ): self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 + # Check if the light has color temperature + if int_type := self.find_dpcode( + description.color_temp, dptype=DPType.INTEGER, prefer_function=True + ): + self._color_temp = int_type + color_modes.add(ColorMode.COLOR_TEMP) + # If light has color but does not have color_temp, check if it has + # work_mode "white" + elif ( + color_supported(color_modes) + and ( + color_mode_enum := self.find_dpcode( + description.color_mode, dptype=DPType.ENUM, prefer_function=True + ) + ) + and WorkMode.WHITE.value in color_mode_enum.range + ): + color_modes.add(ColorMode.WHITE) + self._white_color_mode = ColorMode.WHITE + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 039b8f29290..d793b87854a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -149,6 +149,12 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "tyndj_pyakuuoc": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, + ], "wk_air_conditioner": [ # https://github.com/home-assistant/core/issues/146263 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json new file mode 100644 index 00000000000..973cecabc0b --- /dev/null +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -0,0 +1,145 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1753247726209KOaaPc", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfdb773e4ae317e3915h2i", + "name": "Solar zijpad", + "category": "tyndj", + "product_id": "pyakuuoc", + "product_name": "Solar flood light App panel", + "online": false, + "sub": true, + "time_zone": "+08:00", + "active_time": "2023-03-08T13:24:06+00:00", + "create_time": "2023-03-08T13:24:06+00:00", + "update_time": "2023-03-08T13:24:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "countdown": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_save_energy": { + "type": "Boolean", + "value": {} + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto"] + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "countdown": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "switch_save_energy": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 10, + "scene_data": "", + "countdown": 0, + "switch_save_energy": false, + "battery_percentage": 0, + "device_mode": "manual", + "battery_state": "low" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 5fcf58dda6d..ec8e663f62c 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -249,3 +249,59 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][light.solar_zijpad-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.solar_zijpad', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfdb773e4ae317e3915h2iswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][light.solar_zijpad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.solar_zijpad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 57e73eccda5..80051a08396 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2233,6 +2233,107 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_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.solar_zijpad_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfdb773e4ae317e3915h2ibattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Solar zijpad Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solar_zijpad_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solar_zijpad_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bfdb773e4ae317e3915h2ibattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad Battery state', + }), + 'context': , + 'entity_id': 'sensor.solar_zijpad_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 71aa05329aa..e21fe9c91bd 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1161,6 +1161,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-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': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + '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 saving', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saving', + 'unique_id': 'tuya.bfdb773e4ae317e3915h2iswitch_save_energy', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad Energy saving', + }), + 'context': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 22e054f4cd1e5a24cf890412187f23a6c23a5c63 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 1 Aug 2025 09:24:22 +0200 Subject: [PATCH 0818/1118] Add diagnostics to UISP AirOS (#149631) --- homeassistant/components/airos/diagnostics.py | 33 + .../components/airos/quality_scale.yaml | 2 +- .../airos/snapshots/test_diagnostics.ambr | 623 ++++++++++++++++++ tests/components/airos/test_diagnostics.py | 32 + 4 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/airos/diagnostics.py create mode 100644 tests/components/airos/snapshots/test_diagnostics.ambr create mode 100644 tests/components/airos/test_diagnostics.py diff --git a/homeassistant/components/airos/diagnostics.py b/homeassistant/components/airos/diagnostics.py new file mode 100644 index 00000000000..70fef685c86 --- /dev/null +++ b/homeassistant/components/airos/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for airOS.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import AirOSConfigEntry + +IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related +HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address +TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD] +TO_REDACT_AIROS = [ + "hostname", # Prevent leaking device naming + "essid", # Network SSID + "lat", # GPS latitude to prevent exposing location data. + "lon", # GPS longitude to prevent exposing location data. + *HW_REDACT, + *IP_REDACT, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AirOSConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_HA), + "data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS), + } diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml index a0bacd5ebba..c8c5d209af5 100644 --- a/homeassistant/components/airos/quality_scale.yaml +++ b/homeassistant/components/airos/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: todo discovery: todo docs-data-update: done diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..bc2dedc905a --- /dev/null +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -0,0 +1,623 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'chain_names': list([ + dict({ + 'name': 'Chain 0', + 'number': 1, + }), + dict({ + 'name': 'Chain 1', + 'number': 2, + }), + ]), + 'derived': dict({ + 'mac': '**REDACTED**', + 'mac_interface': 'br0', + }), + 'firewall': dict({ + 'eb6tables': False, + 'ebtables': False, + 'ip6tables': False, + 'iptables': False, + }), + 'genuine': '/images/genuine.png', + 'gps': dict({ + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + }), + 'host': dict({ + 'cpuload': 10.10101, + 'device_id': '03aa0d0b40fed0a47088293584ef5432', + 'devmodel': 'NanoStation 5AC loco', + 'freeram': 16564224, + 'fwversion': 'v8.7.17', + 'height': 3, + 'hostname': '**REDACTED**', + 'loadavg': 0.412598, + 'netrole': 'bridge', + 'power_time': 268683, + 'temperature': 0, + 'time': '2025-06-23 23:06:42', + 'timestamp': 2668313184, + 'totalram': 63447040, + 'uptime': 264888, + }), + 'interfaces': list([ + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'eth0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': 18, + 'duplex': True, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 3984971949, + 'rx_dropped': 0, + 'rx_errors': 4, + 'rx_packets': 73564835, + 'snr': list([ + 30, + 30, + 30, + 30, + ]), + 'speed': 1000, + 'tx_bytes': 209900085624, + 'tx_dropped': 10, + 'tx_errors': 0, + 'tx_packets': 185866883, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'ath0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': None, + 'ipaddr': '**REDACTED**', + 'plugged': False, + 'rx_bytes': 206938324766, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 149767200, + 'snr': None, + 'speed': 0, + 'tx_bytes': 5265602738, + 'tx_dropped': 2005, + 'tx_errors': 0, + 'tx_packets': 52980390, + }), + }), + dict({ + 'enabled': True, + 'hwaddr': '**REDACTED**', + 'ifname': 'br0', + 'mtu': 1500, + 'status': dict({ + 'cable_len': None, + 'duplex': False, + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'plugged': True, + 'rx_bytes': 204802727, + 'rx_dropped': 0, + 'rx_errors': 0, + 'rx_packets': 1791592, + 'snr': None, + 'speed': 0, + 'tx_bytes': 236295176, + 'tx_dropped': 0, + 'tx_errors': 0, + 'tx_packets': 298119, + }), + }), + ]), + 'ntpclient': dict({ + }), + 'portfw': False, + 'provmode': dict({ + }), + 'services': dict({ + 'airview': 2, + 'dhcp6d_stateful': False, + 'dhcpc': False, + 'dhcpd': False, + 'pppoe': False, + }), + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'wireless': dict({ + 'antenna_gain': 13, + 'apmac': '**REDACTED**', + 'aprepeater': False, + 'band': 2, + 'cac_state': 0, + 'cac_timeout': 0, + 'center1_freq': 5530, + 'chanbw': 80, + 'compat_11n': 0, + 'count': 1, + 'dfs': 1, + 'distance': 0, + 'essid': '**REDACTED**', + 'frequency': 5500, + 'hide_essid': 0, + 'ieeemode': '11ACVHT80', + 'mode': 'ap-ptp', + 'noisef': -89, + 'nol_state': 0, + 'nol_timeout': 0, + 'polling': dict({ + 'atpc_status': 2, + 'cb_capacity': 593970, + 'dl_capacity': 647400, + 'ff_cap_rep': False, + 'fixed_frame': False, + 'gps_sync': False, + 'rx_use': 42, + 'tx_use': 6, + 'ul_capacity': 540540, + 'use': 48, + }), + 'rstatus': 5, + 'rx_chainmask': 3, + 'rx_idx': 8, + 'rx_nss': 2, + 'security': 'WPA2', + 'service': dict({ + 'link': 266003, + 'time': 267181, + }), + 'sta': list([ + dict({ + 'airmax': dict({ + 'actual_priority': 0, + 'atpc_status': 2, + 'beam': 0, + 'cb_capacity': 593970, + 'desired_priority': 0, + 'dl_capacity': 647400, + 'rx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 31, + 28, + 33, + 32, + 32, + 32, + 31, + 31, + 31, + 29, + 30, + 32, + 30, + 27, + 34, + 31, + 31, + 30, + 32, + 29, + 31, + 29, + 31, + 33, + 31, + 31, + 32, + 30, + 31, + 34, + 33, + 31, + 30, + 31, + 30, + 31, + 31, + 32, + 31, + 30, + 33, + 31, + 30, + 31, + 27, + 31, + 30, + 30, + 30, + 30, + 30, + 29, + 32, + 34, + 31, + 30, + 28, + 30, + 29, + 35, + 31, + 33, + 32, + 29, + ]), + list([ + 34, + 34, + 35, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 35, + 34, + 33, + 33, + 35, + 34, + 34, + 35, + 34, + 35, + 34, + 34, + 35, + 34, + 34, + 33, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + 35, + 34, + 35, + 33, + 34, + 34, + 34, + 34, + 35, + 35, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 34, + 35, + 35, + ]), + ]), + 'usage': 42, + }), + 'tx': dict({ + 'cinr': 31, + 'evm': list([ + list([ + 32, + 34, + 28, + 33, + 35, + 30, + 31, + 33, + 30, + 30, + 32, + 30, + 29, + 33, + 31, + 29, + 33, + 31, + 31, + 30, + 33, + 34, + 33, + 31, + 33, + 32, + 32, + 31, + 29, + 31, + 30, + 32, + 31, + 30, + 29, + 32, + 31, + 32, + 31, + 31, + 32, + 29, + 31, + 29, + 30, + 32, + 32, + 31, + 32, + 32, + 33, + 31, + 28, + 29, + 31, + 31, + 33, + 32, + 33, + 32, + 32, + 32, + 31, + 33, + ]), + list([ + 37, + 37, + 37, + 38, + 38, + 37, + 36, + 38, + 38, + 37, + 37, + 37, + 37, + 37, + 39, + 37, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 37, + 37, + 38, + 37, + 38, + 37, + 37, + 37, + 37, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 38, + 37, + 37, + 38, + 37, + 36, + 37, + 37, + 37, + 37, + 37, + 37, + 37, + ]), + ]), + 'usage': 6, + }), + 'ul_capacity': 540540, + }), + 'airos_connected': True, + 'cb_capacity_expect': 416000, + 'chainrssi': list([ + 35, + 32, + 0, + ]), + 'distance': 1, + 'dl_avg_linkscore': 100, + 'dl_capacity_expect': 208000, + 'dl_linkscore': 100, + 'dl_rate_expect': 3, + 'dl_signal_expect': -80, + 'last_disc': 1, + 'lastip': '**REDACTED**', + 'mac': '**REDACTED**', + 'noisefloor': -89, + 'remote': dict({ + 'age': 1, + 'airview': 2, + 'antenna_gain': 13, + 'cable_loss': 0, + 'chainrssi': list([ + 33, + 37, + 0, + ]), + 'compat_11n': 0, + 'cpuload': 43.564301, + 'device_id': 'd4f4cdf82961e619328a8f72f8d7653b', + 'distance': 1, + 'ethlist': list([ + dict({ + 'cable_len': 14, + 'duplex': True, + 'enabled': True, + 'ifname': 'eth0', + 'plugged': True, + 'snr': list([ + 30, + 30, + 29, + 30, + ]), + 'speed': 1000, + }), + ]), + 'freeram': 14290944, + 'gps': dict({ + 'fix': 0, + 'lat': '**REDACTED**', + 'lon': '**REDACTED**', + }), + 'height': 2, + 'hostname': '**REDACTED**', + 'ip6addr': '**REDACTED**', + 'ipaddr': '**REDACTED**', + 'mode': 'sta-ptp', + 'netrole': 'bridge', + 'noisefloor': -90, + 'oob': False, + 'platform': 'NanoStation 5AC loco', + 'power_time': 268512, + 'rssi': 38, + 'rx_bytes': 3624206478, + 'rx_chainmask': 3, + 'rx_throughput': 251, + 'service': dict({ + 'link': 265996, + 'time': 267195, + }), + 'signal': -58, + 'sys_id': '0xe7fa', + 'temperature': 0, + 'time': '2025-06-23 23:13:54', + 'totalram': 63447040, + 'tx_bytes': 212308148210, + 'tx_power': -4, + 'tx_ratedata': list([ + 14, + 4, + 372, + 2223, + 4708, + 4037, + 8142, + 485763, + 29420892, + 24748154, + ]), + 'tx_throughput': 16023, + 'unms': dict({ + 'status': 0, + 'timestamp': None, + }), + 'uptime': 265320, + 'version': 'WA.ar934x.v8.7.17.48152.250620.2132', + }), + 'rssi': 37, + 'rx_idx': 8, + 'rx_nss': 2, + 'signal': -59, + 'stats': dict({ + 'rx_bytes': 206938324814, + 'rx_packets': 149767200, + 'rx_pps': 846, + 'tx_bytes': 5265602739, + 'tx_packets': 52980390, + 'tx_pps': 0, + }), + 'tx_idx': 9, + 'tx_latency': 0, + 'tx_lretries': 0, + 'tx_nss': 2, + 'tx_packets': 0, + 'tx_ratedata': list([ + 175, + 4, + 47, + 200, + 673, + 158, + 163, + 138, + 68895, + 19577430, + ]), + 'tx_sretries': 0, + 'ul_avg_linkscore': 88, + 'ul_capacity_expect': 624000, + 'ul_linkscore': 86, + 'ul_rate_expect': 8, + 'ul_signal_expect': -55, + 'uptime': 170281, + }), + ]), + 'sta_disconnected': list([ + ]), + 'throughput': dict({ + 'rx': 9907, + 'tx': 222, + }), + 'tx_chainmask': 3, + 'tx_idx': 9, + 'tx_nss': 2, + 'txpower': -3, + }), + }), + 'entry_data': dict({ + 'host': '**REDACTED**', + 'password': '**REDACTED**', + 'username': 'ubnt', + }), + }) +# --- diff --git a/tests/components/airos/test_diagnostics.py b/tests/components/airos/test_diagnostics.py new file mode 100644 index 00000000000..453e8ff1f03 --- /dev/null +++ b/tests/components/airos/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostic tests for airOS.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.airos.coordinator import AirOSData +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_airos_client: MagicMock, + mock_config_entry: MockConfigEntry, + ap_fixture: AirOSData, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 0d7608f7c5d46a956120a685e39ce384057fcd29 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Aug 2025 10:34:34 +0200 Subject: [PATCH 0819/1118] Deprecate DeviceEntry.suggested_area (#149730) --- .../components/analytics/analytics.py | 1 - .../components/enphase_envoy/diagnostics.py | 2 + homeassistant/helpers/device_registry.py | 16 +- .../components/acaia/snapshots/test_init.ambr | 1 - .../airgradient/snapshots/test_init.ambr | 2 - .../alexa_devices/snapshots/test_init.ambr | 1 - tests/components/analytics/test_analytics.py | 2 - .../aosmith/snapshots/test_device.ambr | 1 - .../apcupsd/snapshots/test_init.ambr | 4 - .../august/snapshots/test_binary_sensor.ambr | 1 - .../august/snapshots/test_lock.ambr | 1 - tests/components/axis/snapshots/test_hub.ambr | 2 - tests/components/bond/test_init.py | 18 +- .../cambridge_audio/snapshots/test_init.ambr | 1 - .../components/deconz/snapshots/test_hub.ambr | 1 - .../snapshots/test_init.ambr | 3 - .../ecovacs/snapshots/test_init.ambr | 1 - .../elgato/snapshots/test_button.ambr | 2 - .../elgato/snapshots/test_light.ambr | 3 - .../elgato/snapshots/test_sensor.ambr | 5 - .../elgato/snapshots/test_switch.ambr | 2 - .../snapshots/test_diagnostics.ambr | 8 - tests/components/esphome/test_manager.py | 35 ++- tests/components/flo/snapshots/test_init.ambr | 2 - .../snapshots/test_init.ambr | 1 - .../snapshots/test_init.ambr | 4 - .../components/homee/snapshots/test_init.ambr | 2 - .../snapshots/test_init.ambr | 116 --------- .../homekit_controller/test_init.py | 2 + .../homewizard/snapshots/test_button.ambr | 1 - .../homewizard/snapshots/test_number.ambr | 2 - .../homewizard/snapshots/test_select.ambr | 1 - .../homewizard/snapshots/test_sensor.ambr | 231 ------------------ .../homewizard/snapshots/test_switch.ambr | 11 - tests/components/hue/test_light_v1.py | 21 +- .../snapshots/test_init.ambr | 1 - .../snapshots/test_init.ambr | 1 - .../iotty/snapshots/test_switch.ambr | 1 - .../ista_ecotrend/snapshots/test_init.ambr | 2 - .../ituran/snapshots/test_init.ambr | 1 - .../kitchen_sink/snapshots/test_switch.ambr | 4 - .../lamarzocco/snapshots/test_init.ambr | 1 - .../lektrico/snapshots/test_init.ambr | 1 - tests/components/lifx/test_config_flow.py | 9 +- .../mastodon/snapshots/test_init.ambr | 1 - .../mealie/snapshots/test_init.ambr | 1 - .../meater/snapshots/test_init.ambr | 1 - .../components/miele/snapshots/test_init.ambr | 1 - tests/components/mqtt/common.py | 20 +- .../myuplink/snapshots/test_init.ambr | 3 - .../netatmo/snapshots/test_init.ambr | 39 --- .../netgear_lte/snapshots/test_init.ambr | 1 - tests/components/nut/test_init.py | 8 +- .../nyt_games/snapshots/test_init.ambr | 3 - .../components/ohme/snapshots/test_init.ambr | 1 - .../ondilo_ico/snapshots/test_init.ambr | 2 - .../onedrive/snapshots/test_init.ambr | 1 - .../onewire/snapshots/test_init.ambr | 22 -- .../snapshots/test_init.ambr | 2 - .../overseerr/snapshots/test_init.ambr | 1 - .../palazzetti/snapshots/test_init.ambr | 1 - .../peblar/snapshots/test_init.ambr | 1 - .../rainforest_raven/snapshots/test_init.ambr | 1 - .../renault/snapshots/test_init.ambr | 5 - tests/components/roku/test_binary_sensor.py | 13 +- tests/components/roku/test_media_player.py | 13 +- tests/components/roku/test_sensor.py | 13 +- .../components/rova/snapshots/test_init.ambr | 1 - .../russound_rio/snapshots/test_init.ambr | 1 - .../samsungtv/snapshots/test_init.ambr | 3 - .../schlage/snapshots/test_init.ambr | 1 - .../sensibo/snapshots/test_entity.ambr | 4 - .../sfr_box/snapshots/test_binary_sensor.ambr | 2 - .../sfr_box/snapshots/test_button.ambr | 1 - .../sfr_box/snapshots/test_sensor.ambr | 1 - .../slide_local/snapshots/test_init.ambr | 1 - .../smartthings/snapshots/test_init.ambr | 68 ------ .../smarty/snapshots/test_init.ambr | 1 - .../smlight/snapshots/test_init.ambr | 1 - tests/components/sonos/test_media_player.py | 12 +- .../squeezebox/snapshots/test_init.ambr | 2 - .../snapshots/test_binary_sensor.ambr | 2 - .../tailwind/snapshots/test_button.ambr | 1 - .../tailwind/snapshots/test_cover.ambr | 2 - .../tailwind/snapshots/test_number.ambr | 1 - .../components/tedee/snapshots/test_init.ambr | 2 - .../components/tedee/snapshots/test_lock.ambr | 1 - .../tesla_fleet/snapshots/test_init.ambr | 4 - .../teslemetry/snapshots/test_init.ambr | 4 - .../components/tile/snapshots/test_init.ambr | 1 - .../tplink/snapshots/test_binary_sensor.ambr | 1 - .../tplink/snapshots/test_button.ambr | 1 - .../tplink/snapshots/test_camera.ambr | 1 - .../tplink/snapshots/test_climate.ambr | 1 - .../components/tplink/snapshots/test_fan.ambr | 1 - .../tplink/snapshots/test_number.ambr | 1 - .../tplink/snapshots/test_select.ambr | 1 - .../tplink/snapshots/test_sensor.ambr | 1 - .../tplink/snapshots/test_siren.ambr | 1 - .../tplink/snapshots/test_switch.ambr | 1 - .../tplink/snapshots/test_vacuum.ambr | 1 - .../components/tuya/snapshots/test_init.ambr | 1 - .../twentemilieu/snapshots/test_calendar.ambr | 1 - .../twentemilieu/snapshots/test_sensor.ambr | 5 - .../uptime/snapshots/test_sensor.ambr | 1 - .../velbus/snapshots/test_init.ambr | 10 - .../components/vesync/snapshots/test_fan.ambr | 12 - .../vesync/snapshots/test_light.ambr | 12 - .../vesync/snapshots/test_sensor.ambr | 12 - .../vesync/snapshots/test_switch.ambr | 12 - .../webostv/snapshots/test_media_player.ambr | 1 - .../whois/snapshots/test_sensor.ambr | 10 - .../withings/snapshots/test_init.ambr | 2 - .../wled/snapshots/test_button.ambr | 1 - .../wled/snapshots/test_number.ambr | 2 - .../wled/snapshots/test_select.ambr | 4 - .../wled/snapshots/test_switch.ambr | 4 - .../wmspro/snapshots/test_cover.ambr | 1 - .../wmspro/snapshots/test_init.ambr | 12 - .../wmspro/snapshots/test_light.ambr | 1 - .../wmspro/snapshots/test_scene.ambr | 1 - .../wolflink/snapshots/test_sensor.ambr | 1 - tests/components/wyoming/test_devices.py | 5 +- .../yale/snapshots/test_binary_sensor.ambr | 1 - .../components/yale/snapshots/test_lock.ambr | 1 - .../snapshots/test_entity_platform.ambr | 2 - tests/helpers/test_device_registry.py | 48 +++- tests/syrupy.py | 2 + 128 files changed, 180 insertions(+), 795 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 8a2a182c796..0d0f5183566 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -430,7 +430,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: "model": device.model, "sw_version": device.sw_version, "hw_version": device.hw_version, - "has_suggested_area": device.suggested_area is not None, "has_configuration_url": device.configuration_url is not None, "via_device": None, } diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index a1a9d4ed6b4..6487830675f 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -116,6 +116,8 @@ async def async_get_config_entry_diagnostics( entities.append({"entity": entity_dict, "state": state_dict}) device_dict = asdict(device) device_dict.pop("_cache", None) + # This can be removed when suggested_area is removed from DeviceEntry + device_dict.pop("_suggested_area") device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index c8b4428a7cc..d3866d8c9c3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -32,6 +32,7 @@ from homeassistant.util.json import format_unserializable_data from . import storage, translation from .debounce import Debouncer +from .deprecation import deprecated_function from .frame import ReportBehavior, report_usage from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType @@ -67,6 +68,7 @@ CONNECTION_ZIGBEE = "zigbee" ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 +# Can be removed when suggested_area is removed from DeviceEntry RUNTIME_ONLY_ATTRS = {"suggested_area"} CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -343,7 +345,8 @@ class DeviceEntry: name: str | None = attr.ib(default=None) primary_config_entry: str | None = attr.ib(default=None) serial_number: str | None = attr.ib(default=None) - suggested_area: str | None = attr.ib(default=None) + # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + _suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) # This value is not stored, just used to keep track of events to fire. @@ -442,6 +445,14 @@ class DeviceEntry: ) ) + @property + @deprecated_function( + "code which ignores suggested_area", breaks_in_ha_version="2026.9" + ) + def suggested_area(self) -> str | None: + """Return the suggested area for this device entry.""" + return self._suggested_area + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: @@ -1197,6 +1208,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("name_by_user", name_by_user), ("serial_number", serial_number), + # Can be removed when suggested_area is removed from DeviceEntry ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -1211,6 +1223,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not new_values: return old + # This condition can be removed when suggested_area is removed from DeviceEntry if not RUNTIME_ONLY_ATTRS.issuperset(new_values): # Change modified_at if we are changing something that we store new_values["modified_at"] = utcnow() @@ -1233,6 +1246,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # firing events for data we have nothing to compare # against since its never saved on disk if RUNTIME_ONLY_ATTRS.issuperset(new_values): + # This can be removed when suggested_area is removed from DeviceEntry return new self.async_schedule_save() diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index c7a11cb58df..d518de056b2 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Kitchen', 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index b3181fddfeb..96ce43260aa 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '84fce612f5b8', - 'suggested_area': None, 'sw_version': '3.1.1', 'via_device_id': None, }) @@ -68,7 +67,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '84fce612f5b8', - 'suggested_area': None, 'sw_version': '3.1.1', 'via_device_id': None, }) diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index e0460c4c173..c396c65246a 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'echo_test_serial_number', - 'suggested_area': None, 'sw_version': 'echo_test_software_version', 'via_device_id': None, }) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 90f3049d8fd..0e14d556620 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1051,7 +1051,6 @@ async def test_devices_payload( "hw_version": "test-hw-version", "integration": "hue", "is_custom_integration": False, - "has_suggested_area": True, "has_configuration_url": True, "via_device": None, }, @@ -1063,7 +1062,6 @@ async def test_devices_payload( "hw_version": None, "integration": "hue", "is_custom_integration": False, - "has_suggested_area": False, "has_configuration_url": False, "via_device": 0, }, diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index e647b7fa6a5..f814106870b 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'serial', - 'suggested_area': 'Basement', 'sw_version': '2.14', 'via_device_id': None, }) diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 39f28b528fc..6ca412f7e34 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.14.14 (31 May 2016) unknown', 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index be5947372f5..c9a7b7ba039 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'tmt100 Name', 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, }) diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 0a594fed1ee..eb2cf7a815a 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'online_with_doorsense Name', 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, }) diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 9e407bfef0b..ab4745011dd 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', - 'suggested_area': None, 'sw_version': '9.10.1', 'via_device_id': None, }) @@ -68,7 +67,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', - 'suggested_area': None, 'sw_version': '9.80.1', 'via_device_id': None, }) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 0aaff0edfe7..c8ced85c933 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -11,7 +11,11 @@ from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from .common import ( @@ -202,7 +206,9 @@ async def test_old_identifiers_are_removed( async def test_smart_by_bond_device_suggested_area( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can setup a smart by bond device and get the suggested area.""" config_entry = MockConfigEntry( @@ -241,11 +247,13 @@ async def test_smart_by_bond_device_suggested_area( device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None - assert device.suggested_area == "Den" + assert device.area_id == area_registry.async_get_area_by_name("Den").id async def test_bridge_device_suggested_area( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we can setup a bridge bond device and get the suggested area.""" config_entry = MockConfigEntry( @@ -289,7 +297,7 @@ async def test_bridge_device_suggested_area( device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None - assert device.suggested_area == "Office" + assert device.area_id == area_registry.async_get_area_by_name("Office").id async def test_device_remove_devices( diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 7f4bbed36f7..71a54cdb001 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0020c2d8', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index 06067b69c17..b171dafbd5d 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 27ffd981b1e..4f965ce8d05 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) @@ -68,7 +67,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) @@ -101,7 +99,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': '5.6.1', 'via_device_id': None, }) diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index e403c937394..642f0db6813 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'E1234567890000000001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 2f1c2107b52..5ff3710dfd7 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -80,7 +80,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -166,7 +165,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 16f20224079..8ee893f6be5 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -112,7 +112,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) @@ -232,7 +231,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) @@ -352,7 +350,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'CN11A1A00001', - 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 3592e88f975..ebf98ff02ae 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -87,7 +87,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -183,7 +182,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -279,7 +277,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -372,7 +369,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -468,7 +464,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index f29c16d0cae..8c75ed137b1 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -79,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) @@ -164,7 +163,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GW24L1A02987', - 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, }) diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 3a7f4e4fb9f..be638168b34 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -60,7 +60,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -308,7 +307,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', - 'suggested_area': None, 'sw_version': None, }), 'entities': list([ @@ -939,7 +937,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -1187,7 +1184,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', - 'suggested_area': None, 'sw_version': None, }), 'entities': list([ @@ -1862,7 +1858,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ @@ -2110,7 +2105,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', - 'suggested_area': None, 'sw_version': None, }), 'entities': list([ @@ -2806,7 +2800,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', - 'suggested_area': None, 'sw_version': None, }), 'entities': list([ @@ -3356,7 +3349,6 @@ 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', - 'suggested_area': None, 'sw_version': '7.6.175', }), 'entities': list([ diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 8d2dd211869..fec957a9560 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -50,6 +50,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( + area_registry as ar, device_registry as dr, entity_registry as er, issue_registry as ir, @@ -1170,6 +1171,7 @@ async def test_esphome_user_services_changes( async def test_esphome_device_with_suggested_area( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, @@ -1184,11 +1186,12 @@ async def test_esphome_device_with_suggested_area( dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) - assert dev.suggested_area == "kitchen" + assert dev.area_id == area_registry.async_get_area_by_name("kitchen").id async def test_esphome_device_area_priority( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, @@ -1207,7 +1210,7 @@ async def test_esphome_device_area_priority( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) # Should use device_info.area.name instead of suggested_area - assert dev.suggested_area == "Living Room" + assert dev.area_id == area_registry.async_get_area_by_name("Living Room").id async def test_esphome_device_with_project( @@ -1535,6 +1538,7 @@ async def test_assist_in_progress_issue_deleted( async def test_sub_device_creation( hass: HomeAssistant, + area_registry: ar.AreaRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: @@ -1571,7 +1575,7 @@ async def test_sub_device_creation( connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} ) assert main_device is not None - assert main_device.suggested_area == "Main Hub" + assert main_device.area_id == area_registry.async_get_area_by_name("Main Hub").id # Check sub devices are created sub_device_1 = device_registry.async_get_device( @@ -1579,7 +1583,9 @@ async def test_sub_device_creation( ) assert sub_device_1 is not None assert sub_device_1.name == "Motion Sensor" - assert sub_device_1.suggested_area == "Living Room" + assert ( + sub_device_1.area_id == area_registry.async_get_area_by_name("Living Room").id + ) assert sub_device_1.via_device_id == main_device.id sub_device_2 = device_registry.async_get_device( @@ -1587,7 +1593,9 @@ async def test_sub_device_creation( ) assert sub_device_2 is not None assert sub_device_2.name == "Light Switch" - assert sub_device_2.suggested_area == "Living Room" + assert ( + sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id + ) assert sub_device_2.via_device_id == main_device.id sub_device_3 = device_registry.async_get_device( @@ -1595,7 +1603,7 @@ async def test_sub_device_creation( ) assert sub_device_3 is not None assert sub_device_3.name == "Temperature Sensor" - assert sub_device_3.suggested_area == "Bedroom" + assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id assert sub_device_3.via_device_id == main_device.id @@ -1731,6 +1739,7 @@ async def test_sub_device_with_empty_name( async def test_sub_device_references_main_device_area( hass: HomeAssistant, + area_registry: ar.AreaRegistry, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: @@ -1772,28 +1781,34 @@ async def test_sub_device_references_main_device_area( connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} ) assert main_device is not None - assert main_device.suggested_area == "Main Hub Area" + assert ( + main_device.area_id == area_registry.async_get_area_by_name("Main Hub Area").id + ) # Check sub device 1 uses main device's area sub_device_1 = device_registry.async_get_device( identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} ) assert sub_device_1 is not None - assert sub_device_1.suggested_area == "Main Hub Area" + assert ( + sub_device_1.area_id == area_registry.async_get_area_by_name("Main Hub Area").id + ) # Check sub device 2 uses Living Room sub_device_2 = device_registry.async_get_device( identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} ) assert sub_device_2 is not None - assert sub_device_2.suggested_area == "Living Room" + assert ( + sub_device_2.area_id == area_registry.async_get_area_by_name("Living Room").id + ) # Check sub device 3 uses Bedroom sub_device_3 = device_registry.async_get_device( identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} ) assert sub_device_3 is not None - assert sub_device_3.suggested_area == "Bedroom" + assert sub_device_3.area_id == area_registry.async_get_area_by_name("Bedroom").id @patch("homeassistant.components.esphome.manager.secrets.token_bytes") diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index edba0ebe162..51e7bbd6dce 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -32,7 +32,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '6.1.1', 'via_device_id': None, }), @@ -67,7 +66,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111112', - 'suggested_area': None, 'sw_version': '1.1.15', 'via_device_id': None, }), diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index d2af92b3f8f..c26d39a5e25 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.2.3', 'via_device_id': None, }) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 0c57935589b..f11791b8ed1 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -59,7 +58,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -90,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -121,7 +118,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr index 664740dbeac..dc56290e93e 100644 --- a/tests/components/homee/snapshots/test_init.ambr +++ b/tests/components/homee/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.2.3', 'via_device_id': None, }) @@ -64,7 +63,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.54', 'via_device_id': , }) diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4540cfd239a..556be38f702 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -34,7 +34,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234', - 'suggested_area': None, 'sw_version': '0.8.16', }), 'entities': list([ @@ -665,7 +664,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000A', - 'suggested_area': None, 'sw_version': '2.1.6', }), 'entities': list([ @@ -747,7 +745,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000D', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -1005,7 +1002,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000B', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -1263,7 +1259,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'A0000A000000000C', - 'suggested_area': None, 'sw_version': '1.6.7', }), 'entities': list([ @@ -1525,7 +1520,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00aa00000a0', - 'suggested_area': None, 'sw_version': '3.3.0', }), 'entities': list([ @@ -1746,7 +1740,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '158d0007c59c6a', - 'suggested_area': None, 'sw_version': '0', }), 'entities': list([ @@ -1923,7 +1916,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0000000123456789', - 'suggested_area': None, 'sw_version': '1.4.7', }), 'entities': list([ @@ -2215,7 +2207,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '111a1111a1a111', - 'suggested_area': None, 'sw_version': '9', }), 'entities': list([ @@ -2349,7 +2340,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00A0000000000', - 'suggested_area': None, 'sw_version': '1.10.931', }), 'entities': list([ @@ -2863,7 +2853,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1020301376', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -3335,7 +3324,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB3C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -3510,7 +3498,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -3992,7 +3979,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB1C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4167,7 +4153,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB2C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4346,7 +4331,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4612,7 +4596,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -4871,7 +4854,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5130,7 +5112,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5389,7 +5370,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5648,7 +5628,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -5914,7 +5893,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6173,7 +6151,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6432,7 +6409,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6698,7 +6674,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -6957,7 +6932,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '4.8.70226', }), 'entities': list([ @@ -7357,7 +7331,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -7623,7 +7596,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -7886,7 +7858,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -8372,7 +8343,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB3C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -8497,7 +8467,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789012', - 'suggested_area': None, 'sw_version': '4.2.394', }), 'entities': list([ @@ -8798,7 +8767,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB1C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -8973,7 +8941,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AB2C', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -9152,7 +9119,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456789016', - 'suggested_area': None, 'sw_version': '4.7.340214', }), 'entities': list([ @@ -9647,7 +9613,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '4.5.130201', }), 'entities': list([ @@ -9958,7 +9923,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AA00A0A00000', - 'suggested_area': None, 'sw_version': '1.2.8', }), 'entities': list([ @@ -10341,7 +10305,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AA00A0A00000', - 'suggested_area': None, 'sw_version': '1.2.9', }), 'entities': list([ @@ -10712,7 +10675,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'C718B3-1', - 'suggested_area': None, 'sw_version': '5.0.18', }), 'entities': list([ @@ -10934,7 +10896,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'C718B3-2', - 'suggested_area': None, 'sw_version': '5.0.18', }), 'entities': list([ @@ -11062,7 +11023,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.family_door_north', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -11236,7 +11196,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -11318,7 +11277,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.kitchen_window', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -11496,7 +11454,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.ceiling_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11629,7 +11586,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11711,7 +11667,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -11849,7 +11804,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'climate.89_living_room', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12197,7 +12151,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12283,7 +12236,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12365,7 +12317,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'light.laundry_smoke_ed78', - 'suggested_area': None, 'sw_version': '1.4.84', }), 'entities': list([ @@ -12551,7 +12502,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.family_door_north', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -12725,7 +12675,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -12807,7 +12756,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'cover.kitchen_window', - 'suggested_area': None, 'sw_version': '3.6.2', }), 'entities': list([ @@ -12985,7 +12933,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.ceiling_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13118,7 +13065,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13200,7 +13146,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13339,7 +13284,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13421,7 +13365,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'fan.living_room_fan', - 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), 'entities': list([ @@ -13560,7 +13503,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'climate.89_living_room', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -13917,7 +13859,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14003,7 +13944,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14085,7 +14025,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'humidifier.humidifier_182a', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14278,7 +14217,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14360,7 +14298,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'humidifier.humidifier_182a', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14553,7 +14490,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'homekit.bridge', - 'suggested_area': None, 'sw_version': '2024.2.0', }), 'entities': list([ @@ -14635,7 +14571,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'light.laundry_smoke_ed78', - 'suggested_area': None, 'sw_version': '1.4.84', }), 'entities': list([ @@ -14836,7 +14771,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00000001', - 'suggested_area': None, 'sw_version': '1.0.0', }), 'entities': list([ @@ -15050,7 +14984,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462395276914', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15197,7 +15130,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462395276939', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15344,7 +15276,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462403113447', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15491,7 +15422,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462403233419', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15638,7 +15568,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462412411853', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15795,7 +15724,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462412413293', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -15952,7 +15880,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462389072572', - 'suggested_area': None, 'sw_version': '45.1.17846', }), 'entities': list([ @@ -16286,7 +16213,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462378982941', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16420,7 +16346,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462378983942', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16554,7 +16479,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462379122122', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16688,7 +16612,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462379123707', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16822,7 +16745,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462383114163', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -16956,7 +16878,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462383114193', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -17090,7 +17011,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '6623462385996792', - 'suggested_area': None, 'sw_version': '1.46.13', }), 'entities': list([ @@ -17224,7 +17144,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '123456', - 'suggested_area': None, 'sw_version': '1.32.1932126170', }), 'entities': list([ @@ -17310,7 +17229,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAA011111111111', - 'suggested_area': None, 'sw_version': '2.2.15', }), 'entities': list([ @@ -17463,7 +17381,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'EUCP03190xxxxx48', - 'suggested_area': None, 'sw_version': '2.3.7', }), 'entities': list([ @@ -17642,7 +17559,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'CNNT061751001372', - 'suggested_area': None, 'sw_version': '1.0.3', }), 'entities': list([ @@ -17862,7 +17778,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'XXXXXXXX', - 'suggested_area': None, 'sw_version': '3.40.XX', }), 'entities': list([ @@ -18162,7 +18077,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '999AAAAAA999', - 'suggested_area': None, 'sw_version': '04.71.04', }), 'entities': list([ @@ -18354,7 +18268,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '39024290', - 'suggested_area': None, 'sw_version': '001.005', }), 'entities': list([ @@ -18487,7 +18400,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '12344331', - 'suggested_area': None, 'sw_version': '08.08', }), 'entities': list([ @@ -18573,7 +18485,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'HH41234', - 'suggested_area': None, 'sw_version': '4.2.3', }), 'entities': list([ @@ -18869,7 +18780,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'BB1121', - 'suggested_area': None, 'sw_version': '4.1.9', }), 'entities': list([ @@ -19007,7 +18917,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAA000', - 'suggested_area': None, 'sw_version': '2.8.1', }), 'entities': list([ @@ -19357,7 +19266,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAA011111111111', - 'suggested_area': None, 'sw_version': '1.4.40', }), 'entities': list([ @@ -19642,7 +19550,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'g738658', - 'suggested_area': None, 'sw_version': '80.0.0', }), 'entities': list([ @@ -19953,7 +19860,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234', - 'suggested_area': None, 'sw_version': '1.0.3', }), 'entities': list([ @@ -20125,7 +20031,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAAAAAAAA', - 'suggested_area': None, 'sw_version': '59', }), 'entities': list([ @@ -20451,7 +20356,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '00aa0000aa0a', - 'suggested_area': None, 'sw_version': '1.0.4', }), 'entities': list([ @@ -20897,7 +20801,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21071,7 +20974,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0101.3521.0436', - 'suggested_area': None, 'sw_version': '1.3.0', }), 'entities': list([ @@ -21153,7 +21055,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '', - 'suggested_area': None, 'sw_version': '', }), 'entities': list([ @@ -21331,7 +21232,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21505,7 +21405,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21679,7 +21578,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -21853,7 +21751,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '0401.3521.0679', - 'suggested_area': None, 'sw_version': '1.3.0', }), 'entities': list([ @@ -21935,7 +21832,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1.0.0', - 'suggested_area': None, 'sw_version': '3.0.8', }), 'entities': list([ @@ -22113,7 +22009,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AAAAAAA000', - 'suggested_area': None, 'sw_version': '004.027.000', }), 'entities': list([ @@ -22242,7 +22137,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1234567890abcd', - 'suggested_area': None, 'sw_version': '', }), 'entities': list([ @@ -22432,7 +22326,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '0.0.0', }), 'entities': list([ @@ -22563,7 +22456,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '3.3.0', }), 'entities': list([ @@ -22978,7 +22870,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '16.0.0', }), 'entities': list([ @@ -23208,7 +23099,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'a1a11a1', - 'suggested_area': None, 'sw_version': '70', }), 'entities': list([ @@ -23290,7 +23180,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'a11b111', - 'suggested_area': None, 'sw_version': '16', }), 'entities': list([ @@ -23516,7 +23405,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '1111111a114a111a', - 'suggested_area': None, 'sw_version': '48', }), 'entities': list([ @@ -23647,7 +23535,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '0.0.0', }), 'entities': list([ @@ -23778,7 +23665,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': '**REDACTED**', - 'suggested_area': None, 'sw_version': '15.0.0', }), 'entities': list([ @@ -23908,7 +23794,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'AM01121849000327', - 'suggested_area': None, 'sw_version': '3.121.2', }), 'entities': list([ @@ -24229,7 +24114,6 @@ 'name_by_user': None, 'primary_config_entry': 'TestData', 'serial_number': 'EU0121203xxxxx07', - 'suggested_area': None, 'sw_version': '1.101.2', }), 'entities': list([ diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 656978a08a2..166fd1a9e65 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -328,6 +328,8 @@ async def test_snapshots( device_dict.pop("created_at", None) device_dict.pop("modified_at", None) device_dict.pop("_cache", None) + # This can be removed when suggested_area is removed from DeviceEntry + device_dict.pop("_suggested_area") devices.append({"device": device_dict, "entities": entities}) diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index a07c0745c45..3b6264367e2 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -80,7 +80,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 3224a0cc63e..b75b89269f1 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -89,7 +89,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -184,7 +183,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index ecfd80e04da..dd331c3f49b 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -90,7 +90,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 9f95e140edc..f870170bae9 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -119,7 +118,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -212,7 +210,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -305,7 +302,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -398,7 +394,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -491,7 +486,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -584,7 +578,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -677,7 +670,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -763,7 +755,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -856,7 +847,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -945,7 +935,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.00', 'via_device_id': None, }) @@ -1030,7 +1019,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1123,7 +1111,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1216,7 +1203,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1309,7 +1295,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1402,7 +1387,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1495,7 +1479,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1588,7 +1571,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1678,7 +1660,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1771,7 +1752,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1864,7 +1844,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -1949,7 +1928,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2038,7 +2016,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2131,7 +2108,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2224,7 +2200,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2317,7 +2292,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2410,7 +2384,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2503,7 +2476,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2596,7 +2568,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2689,7 +2660,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2782,7 +2752,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2875,7 +2844,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -2968,7 +2936,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3061,7 +3028,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3154,7 +3120,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3244,7 +3209,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3334,7 +3298,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3424,7 +3387,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3517,7 +3479,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3610,7 +3571,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3703,7 +3663,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3796,7 +3755,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3889,7 +3847,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -3982,7 +3939,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4075,7 +4031,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4168,7 +4123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4261,7 +4215,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4354,7 +4307,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4439,7 +4391,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -4528,7 +4479,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4618,7 +4568,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4711,7 +4660,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4804,7 +4752,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4897,7 +4844,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -4982,7 +4928,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5075,7 +5020,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5168,7 +5112,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5261,7 +5204,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5354,7 +5296,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5447,7 +5388,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5540,7 +5480,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5633,7 +5572,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5726,7 +5664,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5819,7 +5756,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -5912,7 +5848,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6005,7 +5940,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6090,7 +6024,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6180,7 +6113,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6273,7 +6205,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6358,7 +6289,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6451,7 +6381,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6544,7 +6473,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6637,7 +6565,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6722,7 +6649,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6807,7 +6733,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6906,7 +6831,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -6999,7 +6923,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7092,7 +7015,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7185,7 +7107,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7278,7 +7199,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7363,7 +7283,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7448,7 +7367,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7533,7 +7451,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7618,7 +7535,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7703,7 +7619,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7788,7 +7703,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7877,7 +7791,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -7962,7 +7875,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8047,7 +7959,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'gas_meter_G001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8136,7 +8047,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'heat_meter_H001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8225,7 +8135,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8310,7 +8219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8399,7 +8307,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'water_meter_W001', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -8492,7 +8399,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8582,7 +8488,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8675,7 +8580,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8768,7 +8672,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8861,7 +8764,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -8946,7 +8848,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9039,7 +8940,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9132,7 +9032,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9225,7 +9124,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9318,7 +9216,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9411,7 +9308,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9504,7 +9400,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9597,7 +9492,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9690,7 +9584,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9783,7 +9676,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9876,7 +9768,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -9969,7 +9860,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10054,7 +9944,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10144,7 +10033,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10237,7 +10125,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10322,7 +10209,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10415,7 +10301,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10508,7 +10393,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10601,7 +10485,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10686,7 +10569,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10771,7 +10653,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10870,7 +10751,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -10963,7 +10843,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11056,7 +10935,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11149,7 +11027,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11242,7 +11119,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11327,7 +11203,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11412,7 +11287,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11497,7 +11371,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11582,7 +11455,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11667,7 +11539,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11752,7 +11623,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11841,7 +11711,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -11926,7 +11795,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12011,7 +11879,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12100,7 +11967,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12189,7 +12055,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12274,7 +12139,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12363,7 +12227,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -12456,7 +12319,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12546,7 +12408,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12639,7 +12500,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12732,7 +12592,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12825,7 +12684,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -12918,7 +12776,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13011,7 +12868,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13104,7 +12960,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13197,7 +13052,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13290,7 +13144,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13383,7 +13236,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13476,7 +13328,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13569,7 +13420,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13662,7 +13512,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13755,7 +13604,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13848,7 +13696,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -13933,7 +13780,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14026,7 +13872,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14111,7 +13956,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14204,7 +14048,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14297,7 +14140,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14390,7 +14232,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14483,7 +14324,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14576,7 +14416,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14669,7 +14508,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14762,7 +14600,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14847,7 +14684,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -14932,7 +14768,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15017,7 +14852,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15102,7 +14936,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15187,7 +15020,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15272,7 +15104,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15361,7 +15192,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15446,7 +15276,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.19', 'via_device_id': None, }) @@ -15535,7 +15364,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15628,7 +15456,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15721,7 +15548,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15814,7 +15640,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15907,7 +15732,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -15992,7 +15816,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -16081,7 +15904,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16174,7 +15996,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16267,7 +16088,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16360,7 +16180,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16453,7 +16272,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16546,7 +16364,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16639,7 +16456,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16729,7 +16545,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16822,7 +16637,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -16915,7 +16729,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -17008,7 +16821,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -17093,7 +16905,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -17182,7 +16993,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17275,7 +17085,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17364,7 +17173,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17449,7 +17257,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -17538,7 +17345,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17631,7 +17437,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17724,7 +17529,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17817,7 +17621,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -17910,7 +17713,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18003,7 +17805,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18096,7 +17897,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18186,7 +17986,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18279,7 +18078,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18372,7 +18170,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18457,7 +18254,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18546,7 +18342,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18639,7 +18434,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18732,7 +18526,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18825,7 +18618,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -18918,7 +18710,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19011,7 +18802,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19104,7 +18894,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19197,7 +18986,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19290,7 +19078,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19383,7 +19170,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19476,7 +19262,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19569,7 +19354,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19662,7 +19446,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19752,7 +19535,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19842,7 +19624,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -19932,7 +19713,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20025,7 +19805,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20118,7 +19897,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20211,7 +19989,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20304,7 +20081,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20397,7 +20173,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20490,7 +20265,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20583,7 +20357,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20676,7 +20449,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20769,7 +20541,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20862,7 +20633,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -20947,7 +20717,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index c4e67003b58..49916a59d9e 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -79,7 +79,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -164,7 +163,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -250,7 +248,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -335,7 +332,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -420,7 +416,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.03', 'via_device_id': None, }) @@ -506,7 +501,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -591,7 +585,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -676,7 +669,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.07', 'via_device_id': None, }) @@ -761,7 +753,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.03', 'via_device_id': None, }) @@ -846,7 +837,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) @@ -931,7 +921,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '3.06', 'via_device_id': None, }) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 807996f1093..5f287b1d8e3 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -11,7 +11,11 @@ from homeassistant.components.light import ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.util import color as color_util from .conftest import create_config_entry @@ -776,6 +780,7 @@ def test_hs_color() -> None: async def test_group_features( hass: HomeAssistant, + area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, mock_bridge_v1: Mock, @@ -966,16 +971,22 @@ async def test_group_features( entry = entity_registry.async_get("light.hue_lamp_1") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area is None + assert device_entry.area_id is None entry = entity_registry.async_get("light.hue_lamp_2") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Living Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living Room").id + ) entry = entity_registry.async_get("light.hue_lamp_3") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Living Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living Room").id + ) entry = entity_registry.async_get("light.hue_lamp_4") device_entry = device_registry.async_get(entry.device_id) - assert device_entry.suggested_area == "Dining Room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Dining Room").id + ) diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 1428a75d7b4..e0627ad9da8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': 'Garden', 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index b7aa14ef0bf..e2b8eeba811 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 058a5d35cd0..04712dbf022 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -39,7 +39,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 7329eec7f70..6a5f5371a9d 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index b97aef6027b..456687407e2 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '12345678', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index 9c9f31a2544..350ac169938 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -75,7 +75,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -108,7 +107,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -189,7 +187,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -222,7 +219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 18b2fd0fbc3..f11057f8620 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -35,7 +35,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'GS012345', - 'suggested_area': None, 'sw_version': 'v1.17', 'via_device_id': None, }) diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index 35183bf5d75..a935f5cfa14 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '500006', - 'suggested_area': None, 'sw_version': '1.44', 'via_device_id': None, }) diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index e2a35bcb1b1..1b09d742876 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -14,7 +14,11 @@ from homeassistant.components.lifx.const import CONF_SERIAL from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -585,6 +589,7 @@ async def test_refuse_relays(hass: HomeAssistant) -> None: async def test_suggested_area( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: @@ -624,4 +629,4 @@ async def test_suggested_area( entity = entity_registry.async_get(entity_id) device = device_registry.async_get(entity.device_id) - assert device.suggested_area == "My LIFX Group" + assert device.area_id == area_registry.async_get_area_by_name("My LIFX Group").id diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 46fb4c1d4e0..4d3e9d7aeab 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, }) diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index aada173ffc3..e3a9e608911 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'v1.10.2', 'via_device_id': None, }) diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 68e4ba32a4a..95335942de6 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index eee976ab09f..9feeeb6523b 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'Dummy_Appliance_1', - 'suggested_area': None, 'sw_version': '31.17', 'via_device_id': None, }) diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 15e203eab06..fdaed0c323f 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -32,7 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HassJobType, HomeAssistant from homeassistant.generated.mqtt import MQTT -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -1415,13 +1419,14 @@ async def help_test_entity_device_info_with_identifier( config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) config["unique_id"] = "veryunique" - registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + device_registry = dr.async_get(hass) data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1430,7 +1435,7 @@ async def help_test_entity_device_info_with_identifier( assert device.model_id == "XYZ001" assert device.hw_version == "rev1" assert device.sw_version == "0.1-beta" - assert device.suggested_area == "default_area" + assert device.area_id == area_registry.async_get_area_by_name("default_area").id assert device.configuration_url == "http://example.com" @@ -1450,13 +1455,14 @@ async def help_test_entity_device_info_with_connection( config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_MAC) config["unique_id"] = "veryunique" - registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + device_registry = dr.async_get(hass) data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device( + device = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -1467,7 +1473,7 @@ async def help_test_entity_device_info_with_connection( assert device.model_id == "XYZ001" assert device.hw_version == "rev1" assert device.sw_version == "0.1-beta" - assert device.suggested_area == "default_area" + assert device.area_id == area_registry.async_get_area_by_name("default_area").id assert device.configuration_url == "http://example.com" diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 14be11c36ec..56fb26b4084 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10001', - 'suggested_area': None, 'sw_version': '9682R7A', 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10002', - 'suggested_area': None, 'sw_version': '9682R7B', 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '10003', - 'suggested_area': None, 'sw_version': '9682R7C', 'via_device_id': None, }) diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 35e7f7efc29..95fb1f9ed45 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Corridor', 'sw_version': None, 'via_device_id': None, }) @@ -159,7 +155,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -192,7 +187,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -225,7 +219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -258,7 +251,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -291,7 +283,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -324,7 +315,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -357,7 +347,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -390,7 +379,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -423,7 +411,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -456,7 +443,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -489,7 +475,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -522,7 +507,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -555,7 +539,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -588,7 +571,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -621,7 +603,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -654,7 +635,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -687,7 +667,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -720,7 +699,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -753,7 +731,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -786,7 +763,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -819,7 +795,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -852,7 +827,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -885,7 +859,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -918,7 +891,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -951,7 +923,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -984,7 +955,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1017,7 +987,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1050,7 +1019,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Bureau', 'sw_version': None, 'via_device_id': None, }) @@ -1083,7 +1051,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Livingroom', 'sw_version': None, 'via_device_id': None, }) @@ -1116,7 +1083,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Entrada', 'sw_version': None, 'via_device_id': None, }) @@ -1149,7 +1115,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Cocina', 'sw_version': None, 'via_device_id': None, }) @@ -1182,7 +1147,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1215,7 +1179,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1248,7 +1211,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1281,7 +1243,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index 2a806be8ae1..2980e3f35f0 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'FFFFFFFFFFFFF', - 'suggested_area': None, 'sw_version': 'EC25AFFDR07A09M4G', 'via_device_id': None, }) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 6f1fb94478d..18c038c17a0 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr from homeassistant.setup import async_setup_component from .util import _get_mock_nutclient, async_init_integration @@ -247,6 +247,7 @@ async def test_serial_number( async def test_device_location( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test for suggested location on device.""" @@ -269,7 +270,10 @@ async def test_device_location( ) assert device_entry is not None - assert device_entry.suggested_area == mock_device_location + assert ( + device_entry.area_id + == area_registry.async_get_area_by_name(mock_device_location).id + ) async def test_update_options(hass: HomeAssistant) -> None: diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index d9ce6f15a4d..5ca9a2d8df2 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index ccf09f546cf..2e8304489d9 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'chargerid', - 'suggested_area': None, 'sw_version': 'v2.65', 'via_device_id': None, }) diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 07e56a78fae..787551ad90e 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.7.1-stable', 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.7.1-stable', 'via_device_id': None, }) diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 9b2ed7e4d94..2f9cfc1a038 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 9b2a0e00a62..26ed15fc897 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': , }) @@ -159,7 +155,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -192,7 +187,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -225,7 +219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -258,7 +251,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -291,7 +283,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -324,7 +315,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222222', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -357,7 +347,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222223', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -390,7 +379,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -423,7 +411,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -456,7 +443,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -489,7 +475,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -522,7 +507,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -555,7 +539,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -588,7 +571,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '222222222222', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -621,7 +603,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -654,7 +635,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111111', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -687,7 +667,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111112', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) @@ -720,7 +699,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '111111111113', - 'suggested_area': None, 'sw_version': '3.2', 'via_device_id': None, }) diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index 4eff869b016..0058416b254 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -21,7 +21,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -48,7 +47,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 2709f532ef6..71c1b9ffd3a 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index fc96cab4fad..b69982d9c08 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.0.0', 'via_device_id': None, }) diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 8a7cefc523d..97c0737e402 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -35,7 +35,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '23-45-A4O-MOF', - 'suggested_area': None, 'sw_version': '1.6.1+1+WL-1', 'via_device_id': None, }) diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 8a143f9963f..f34d33d6c24 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -32,7 +32,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '2.0.0 (7400)', 'via_device_id': None, }), diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index 9a10083b227..defb0f249ff 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -63,7 +62,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -98,7 +96,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -133,7 +130,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -168,7 +164,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index c3aec4f0968..bc5022a7724 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON from homeassistant.components.roku.const import DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from . import UPNP_SERIAL @@ -77,12 +81,13 @@ async def test_roku_binary_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_binary_sensors( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -158,4 +163,6 @@ async def test_rokutv_binary_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 7586e85b715..2607c79086a 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -60,7 +60,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -100,7 +104,7 @@ async def test_setup( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/roku3-idle.json"], indirect=True) @@ -118,6 +122,7 @@ async def test_idle_setup( @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_setup( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -146,7 +151,9 @@ async def test_tv_setup( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) @pytest.mark.parametrize( diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py index e65424e3e66..72f57729cc4 100644 --- a/tests/components/roku/test_sensor.py +++ b/tests/components/roku/test_sensor.py @@ -12,7 +12,11 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from . import UPNP_SERIAL @@ -60,12 +64,13 @@ async def test_roku_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "7.5.0" assert device_entry.hw_version == "4200X" - assert device_entry.suggested_area is None + assert device_entry.area_id is None @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_sensors( hass: HomeAssistant, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, @@ -106,4 +111,6 @@ async def test_rokutv_sensors( assert device_entry.entry_type is None assert device_entry.sw_version == "9.2.0" assert device_entry.hw_version == "7820X" - assert device_entry.suggested_area == "Living room" + assert ( + device_entry.area_id == area_registry.async_get_area_by_name("Living room").id + ) diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 8eb77006061..3715f994fb0 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index e3185a06b24..0fcebb8a6e5 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index b29b824a7dd..f9006c7fd52 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -32,7 +32,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -67,7 +66,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -106,7 +104,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index a7f94b80038..4e57ad5d5c6 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0', 'via_device_id': None, }) diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index 80ee847cb55..ee0b3835da4 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -32,7 +32,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': 'Hallway', 'sw_version': 'SKY30046', 'via_device_id': None, }), @@ -67,7 +66,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0987654321', - 'suggested_area': 'Kitchen', 'sw_version': 'PUR00111', 'via_device_id': None, }), @@ -102,7 +100,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0987654329', - 'suggested_area': 'Bedroom', 'sw_version': 'PUR00111', 'via_device_id': None, }), @@ -133,7 +130,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'V17', 'via_device_id': , }), diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 0ee34eebf3f..f0193b6ce1c 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), @@ -161,7 +160,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index 39dd9e512ae..e3e5475ca34 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index cd762a4b2ea..681c3a84191 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, }), diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index 5b1a9f5ce2f..e2dec748e2a 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890ab', - 'suggested_area': None, 'sw_version': 2, 'via_device_id': None, }) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 6ce3992d2b4..d63ac4e9ab4 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -30,7 +30,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -63,7 +62,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Toilet', 'sw_version': None, 'via_device_id': None, }) @@ -96,7 +94,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -129,7 +126,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -162,7 +158,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -195,7 +190,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -228,7 +222,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -261,7 +254,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -294,7 +286,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -327,7 +318,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', 'via_device_id': None, }) @@ -360,7 +350,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', 'via_device_id': None, }) @@ -393,7 +382,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -426,7 +414,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, }) @@ -459,7 +446,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) @@ -492,7 +478,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -525,7 +510,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -558,7 +542,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) @@ -591,7 +574,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', 'via_device_id': None, }) @@ -624,7 +606,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', 'via_device_id': None, }) @@ -657,7 +638,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, }) @@ -690,7 +670,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20240616.213423', 'via_device_id': None, }) @@ -723,7 +702,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, }) @@ -756,7 +734,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250123.105306', 'via_device_id': None, }) @@ -789,7 +766,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) @@ -822,7 +798,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -855,7 +830,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -888,7 +862,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '20250317.1', 'via_device_id': None, }) @@ -921,7 +894,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) @@ -954,7 +926,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', 'via_device_id': None, }) @@ -987,7 +958,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -1020,7 +990,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -1053,7 +1022,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, }) @@ -1086,7 +1054,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) @@ -1119,7 +1086,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', 'via_device_id': None, }) @@ -1152,7 +1118,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1185,7 +1150,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250206213001', 'via_device_id': None, }) @@ -1218,7 +1182,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250206151734', 'via_device_id': None, }) @@ -1251,7 +1214,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '250308073247', 'via_device_id': None, }) @@ -1284,7 +1246,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1317,7 +1278,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1350,7 +1310,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1383,7 +1342,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1416,7 +1374,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1449,7 +1406,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1482,7 +1438,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1515,7 +1470,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.122.2', 'via_device_id': None, }) @@ -1548,7 +1502,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.122.2', 'via_device_id': None, }) @@ -1581,7 +1534,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'HW-Q80RWWB-1012.6', 'via_device_id': None, }) @@ -1614,7 +1566,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1647,7 +1598,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1680,7 +1630,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'V310XXU1AWK1', 'via_device_id': None, }) @@ -1713,7 +1662,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1746,7 +1694,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -1779,7 +1726,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1812,7 +1758,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '6004971003', 'via_device_id': None, }) @@ -1845,7 +1790,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'SKY40147', 'via_device_id': None, }) @@ -1878,7 +1822,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1911,7 +1854,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -1944,7 +1886,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.3.1 Build 240621 Rel.162048', 'via_device_id': None, }) @@ -1977,7 +1918,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) @@ -2010,7 +1950,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'SAT-MT8532D24WWC-1016.0', 'via_device_id': None, }) @@ -2043,7 +1982,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'latest', 'via_device_id': None, }) @@ -2076,7 +2014,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) @@ -2109,7 +2046,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2142,7 +2078,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2175,7 +2110,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2208,7 +2142,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -2245,7 +2178,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', 'sw_version': '000.055.00005', 'via_device_id': None, }) diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index a292cc97f47..ffa30051726 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 127, 'via_device_id': None, }) diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index ba374199254..8f533a42e36 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, }) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index b15d7698e05..84ad624cdc8 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -54,7 +54,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import area_registry as ar, entity_registry as er from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, CONNECTION_UPNP, @@ -83,11 +83,15 @@ async def test_device_registry( assert reg_device.manufacturer == "Sonos" assert reg_device.name == "Zone A" # Default device provides battery info, area should not be suggested - assert reg_device.suggested_area is None + assert reg_device.area_id is None async def test_device_registry_not_portable( - hass: HomeAssistant, device_registry: DeviceRegistry, async_setup_sonos, soco + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: DeviceRegistry, + async_setup_sonos, + soco, ) -> None: """Test non-portable sonos device registered in the device registry to ensure area suggested.""" soco.get_battery_info.return_value = {} @@ -97,7 +101,7 @@ async def test_device_registry_not_portable( identifiers={("sonos", "RINCON_test")} ) assert reg_device is not None - assert reg_device.suggested_area == "Zone A" + assert reg_device.area_id == area_registry.async_get_area_by_name("Zone A").id async def test_entity_basic( diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index 3fc65be834a..39664f9ecf2 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '', 'via_device_id': , }) @@ -72,7 +71,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '', 'via_device_id': , }) diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 5d166018160..a5a591af94c 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -76,7 +76,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) @@ -158,7 +157,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 0e4bb4e4e41..627f05432d2 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -80,7 +80,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': None, }) diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index a1a98b028e3..54c648ba21b 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -77,7 +77,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) @@ -160,7 +159,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': , }) diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index ffa2c5df7fd..acabe061420 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -89,7 +89,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '10.10', 'via_device_id': None, }) diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 28b5ef7a7ed..cfaca7b81f3 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '0000-0000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index a568a7dcd82..b7bf9e6bfa5 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -76,7 +76,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index c482d33de86..a669813a3a5 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123456', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'LRWXF7EK4KC700000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '234', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index f1011034d63..39fc8d04984 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123456', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'LRW3F7EK4NC700000', - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '123', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '234', - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index ffdf6a6251a..0c3e1faf090 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '01.12.14.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index c8251bccd4f..c12f73bd737 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -430,7 +430,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 84cc8f73bf3..7d49d2aedbc 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -612,7 +612,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index f50c5d70362..a0282401e58 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -82,7 +82,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index df63291175a..4a38bdbbe59 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -92,7 +92,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': , }) diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index ad0321accef..eb42e2a7298 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -196,7 +196,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 5ff1d9c5458..bc71313bf96 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index 9fc5181c45d..6bcd24521e4 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 5c22c2f7d83..f95390a8a57 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index 761df4fcf21..7f90915f624 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 4b04587db05..98584c79759 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 68d14270b55..e5b28f5ac7a 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }) diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 084e9a84401..fc30460bcc0 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 915c0f5080e..68f5a7b6adf 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -107,7 +107,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 9e8bb6f7381..ad435a833ee 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -76,7 +76,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -158,7 +157,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -240,7 +238,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -322,7 +319,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -404,7 +400,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 5c9ed6d4683..cb4563e0fb5 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -69,7 +69,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 037ab7e6236..29f92126f95 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }), @@ -59,7 +58,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': None, }), @@ -90,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '2.0.0', 'via_device_id': None, }), @@ -121,7 +118,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6g7', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': , }), @@ -152,7 +148,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '12345', - 'suggested_area': None, 'sw_version': '1.0.1', 'via_device_id': , }), @@ -183,7 +178,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', - 'suggested_area': None, 'sw_version': '1.0.0', 'via_device_id': , }), @@ -214,7 +208,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'asdfghjk', - 'suggested_area': None, 'sw_version': '3.0.0', 'via_device_id': , }), @@ -245,7 +238,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'qwerty1234567', - 'suggested_area': None, 'sw_version': '1.1.1', 'via_device_id': , }), @@ -276,7 +268,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': 'qwerty123', - 'suggested_area': None, 'sw_version': '1.0.1', 'via_device_id': , }), @@ -307,7 +298,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234', - 'suggested_area': None, 'sw_version': '1.0.1', 'via_device_id': , }), diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fe330b82ca7..212535862f5 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -129,7 +128,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -229,7 +227,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -331,7 +328,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -433,7 +429,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -472,7 +467,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -511,7 +505,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -550,7 +543,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -589,7 +581,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -628,7 +619,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -734,7 +724,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -773,7 +762,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 20bf56ef9c4..eac595cc0e9 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -67,7 +66,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -106,7 +104,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -145,7 +142,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -184,7 +180,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -277,7 +272,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -372,7 +366,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -411,7 +404,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -450,7 +442,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -489,7 +480,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -528,7 +518,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -636,7 +625,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index a47de22f68b..6aa25e0763a 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -163,7 +162,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -252,7 +250,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -438,7 +435,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -624,7 +620,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -663,7 +658,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -702,7 +696,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -792,7 +785,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -882,7 +874,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1245,7 +1236,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1284,7 +1274,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -1323,7 +1312,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index edd2eee8b1f..8947ac40424 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -28,7 +28,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -113,7 +112,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -198,7 +196,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -283,7 +280,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -368,7 +364,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -407,7 +402,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -446,7 +440,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -531,7 +524,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -616,7 +608,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -702,7 +693,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -787,7 +777,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), @@ -826,7 +815,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }), diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 9c097b166ec..d0a1142618a 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -63,7 +63,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '1234567890', - 'suggested_area': None, 'sw_version': 'major.minor', 'via_device_id': None, }) diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 67f6baf45bb..38f125ad712 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -75,7 +75,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -157,7 +156,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -243,7 +241,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -325,7 +322,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -407,7 +403,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -488,7 +483,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -569,7 +563,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -650,7 +643,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -731,7 +723,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -864,7 +855,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index ec711def829..88d9ff94b1d 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index d8a29ed7c48..26f8817fa06 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -80,7 +80,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 877c8baa93e..5503b9a733d 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -88,7 +88,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -182,7 +181,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 6cfbe1de5d4..dc8a2f09445 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -90,7 +90,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -322,7 +321,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -416,7 +414,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.99.0b1', 'via_device_id': None, }) @@ -510,7 +507,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.99.0b1', 'via_device_id': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index c32bc314cc0..09c86d81d44 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -81,7 +81,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -166,7 +165,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -252,7 +250,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) @@ -338,7 +335,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': '0.14.4', 'via_device_id': None, }) diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 53b2f6205cb..026785c9e1c 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 147d66f2b69..9d60cf8c907 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '19239', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -60,7 +59,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -93,7 +91,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -126,7 +123,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '19239', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -159,7 +155,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '58717', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -192,7 +187,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -225,7 +219,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '116682', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -258,7 +251,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '172555', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -291,7 +283,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '18894', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -324,7 +315,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '230952', - 'suggested_area': 'Wohnbereich', 'sw_version': None, 'via_device_id': , }) @@ -357,7 +347,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '284942', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) @@ -390,7 +379,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '328518', - 'suggested_area': 'Alle', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index d6ccebfb5ea..694fb2d51e4 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '97358', - 'suggested_area': 'Terrasse', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index b5dddb368c9..97f47dc7f15 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -41,7 +41,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': '42581', - 'suggested_area': 'Raum 0', 'sw_version': None, 'via_device_id': , }) diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index c5b23cc8e79..3d6e3fea3b5 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py index 24423264f93..d03f2622c71 100644 --- a/tests/components/wyoming/test_devices.py +++ b/tests/components/wyoming/test_devices.py @@ -8,13 +8,14 @@ from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import area_registry as ar, device_registry as dr async def test_device_registry_info( hass: HomeAssistant, satellite_device: SatelliteDevice, satellite_config_entry: ConfigEntry, + area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry.""" @@ -26,7 +27,7 @@ async def test_device_registry_info( ) assert device is not None assert device.name == "Test Satellite" - assert device.suggested_area == "Office" + assert device.area_id == area_registry.async_get_area_by_name("Office").id # Check associated entities assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index 9db0d760efb..df0e604c550 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -27,7 +27,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'tmt100 Name', 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, }) diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index 00653a9b0c1..dd2faa8b69e 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'online_with_doorsense Name', 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, }) diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 55ff772e08e..ce0abffd03c 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -31,7 +31,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Heliport', 'sw_version': 'test-sw', 'via_device_id': , }) @@ -68,7 +67,6 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Heliport', 'sw_version': 'test-sw', 'via_device_id': , }) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a66684c94e3..4247da296fd 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -107,7 +107,6 @@ async def test_get_or_create_returns_same_entry( assert entry3.model == "model" assert entry3.name == "name" assert entry3.sw_version == "sw-version" - assert entry3.suggested_area == "Game Room" assert entry3.area_id == game_room_area.id await hass.async_block_till_done() @@ -409,7 +408,6 @@ async def test_loading_from_storage( name="name", primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", - suggested_area=None, # Not stored sw_version="version", ) assert isinstance(entry.config_entries, set) @@ -2509,13 +2507,13 @@ async def test_loading_saving_data( # Ensure a save/load cycle does not keep suggested area new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")}) - assert orig_kitchen_light.suggested_area == "Kitchen" + assert orig_kitchen_light.area_id == "kitchen" - orig_kitchen_light_witout_suggested_area = device_registry.async_update_device( + orig_kitchen_light_without_suggested_area = device_registry.async_update_device( orig_kitchen_light.id, suggested_area=None ) - assert orig_kitchen_light_witout_suggested_area.suggested_area is None - assert orig_kitchen_light_witout_suggested_area == new_kitchen_light + assert orig_kitchen_light_without_suggested_area.area_id == "kitchen" + assert orig_kitchen_light_without_suggested_area == new_kitchen_light async def test_no_unnecessary_changes( @@ -3225,7 +3223,6 @@ async def test_update_suggested_area( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bla", "123")}, ) - assert not entry.suggested_area assert entry.area_id is None suggested_area = "Pool" @@ -3237,7 +3234,6 @@ async def test_update_suggested_area( assert mock_save.call_count == 1 assert updated_entry != entry - assert updated_entry.suggested_area == suggested_area pool_area = area_registry.async_get_area_by_name("Pool") assert pool_area is not None @@ -3267,7 +3263,7 @@ async def test_update_suggested_area( assert len(update_events) == 2 assert mock_save_2.call_count == 0 assert updated_entry != entry - assert updated_entry.suggested_area == "Other" + assert updated_entry.area_id == pool_area.id async def test_cleanup_device_registry( @@ -3475,7 +3471,6 @@ async def test_restore_device( name=None, primary_config_entry=entry_id, serial_number=None, - suggested_area=None, sw_version=None, ) # This will restore the original device, user customizations of @@ -4905,3 +4900,36 @@ async def test_connections_validator() -> None: """Test checking connections validator.""" with pytest.raises(ValueError, match="Invalid mac address format"): dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) + + +async def test_suggested_area_deprecation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Make sure we do not duplicate entries.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + sw_version="sw-version", + name="name", + manufacturer="manufacturer", + model="model", + suggested_area="Game Room", + ) + + game_room_area = area_registry.async_get_area_by_name("Game Room") + assert game_room_area is not None + assert len(area_registry.areas) == 1 + + assert len(device_registry.devices) == 1 + assert entry.area_id == game_room_area.id + assert entry.suggested_area == "Game Room" + + assert ( + "The deprecated function suggested_area was called. It will be removed in " + "HA Core 2026.9. Use code which ignores suggested_area instead" + ) in caplog.text diff --git a/tests/syrupy.py b/tests/syrupy.py index e028d5839cb..642e5a519b2 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -173,6 +173,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): if serialized["primary_config_entry"] is not None: serialized["primary_config_entry"] = ANY serialized.pop("_cache") + # This can be removed when suggested_area is removed from DeviceEntry + serialized.pop("_suggested_area") return cls._remove_created_and_modified_at(serialized) @classmethod From 924a86dfb609b9f87ce0ca11ea9b1d3182154e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 1 Aug 2025 09:51:48 +0100 Subject: [PATCH 0820/1118] Add nameservers to supervisor system health response (#149749) --- homeassistant/components/hassio/strings.json | 1 + homeassistant/components/hassio/system_health.py | 10 ++++++++++ tests/components/hassio/test_system_health.py | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 6d67b4b79c0..1e312ee34d9 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -9,6 +9,7 @@ "healthy": "Healthy", "host_os": "Host operating system", "installed_addons": "Installed add-ons", + "nameservers": "Nameservers", "supervisor_api": "Supervisor API", "supervisor_version": "Supervisor version", "supported": "Supported", diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index bc8da2a2a92..0a7e9b51e97 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -54,6 +54,15 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "error": "Unsupported", } + nameservers = set() + for interface in network_info.get("interfaces", []): + if not interface.get("primary"): + continue + if ipv4 := interface.get("ipv4"): + nameservers.update(ipv4.get("nameservers", [])) + if ipv6 := interface.get("ipv6"): + nameservers.update(ipv6.get("nameservers", [])) + information = { "host_os": host_info.get("operating_system"), "update_channel": info.get("channel"), @@ -62,6 +71,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "docker_version": info.get("docker"), "disk_total": f"{host_info.get('disk_total')} GB", "disk_used": f"{host_info.get('disk_used')} GB", + "nameservers": ", ".join(nameservers), "healthy": healthy, "supported": supported, "host_connectivity": network_info.get("host_internet"), diff --git a/tests/components/hassio/test_system_health.py b/tests/components/hassio/test_system_health.py index c4c2b861e6e..4839486810a 100644 --- a/tests/components/hassio/test_system_health.py +++ b/tests/components/hassio/test_system_health.py @@ -55,6 +55,10 @@ async def test_hassio_system_health( hass.data["hassio_network_info"] = { "host_internet": True, "supervisor_internet": True, + "interfaces": [ + {"primary": False, "ipv4": {"nameservers": ["9.9.9.9"]}}, + {"primary": True, "ipv4": {"nameservers": ["1.1.1.1"]}}, + ], } with patch.dict(os.environ, MOCK_ENVIRON): @@ -76,6 +80,7 @@ async def test_hassio_system_health( "host_os": "Home Assistant OS 5.9", "installed_addons": "Awesome Addon (1.0.0)", "ntp_synchronized": True, + "nameservers": "1.1.1.1", "supervisor_api": "ok", "supervisor_version": "supervisor-2020.11.1", "supported": True, From d77a3fca83a723a3d346a345823599748604b7e8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Aug 2025 11:01:26 +0200 Subject: [PATCH 0821/1118] Exclude is_new from DeviceEntry snapshots (#149801) --- .../components/enphase_envoy/diagnostics.py | 1 + .../components/acaia/snapshots/test_init.ambr | 1 - .../airgradient/snapshots/test_init.ambr | 2 - .../alexa_devices/snapshots/test_init.ambr | 1 - .../aosmith/snapshots/test_device.ambr | 1 - .../apcupsd/snapshots/test_init.ambr | 4 - .../august/snapshots/test_binary_sensor.ambr | 1 - .../august/snapshots/test_lock.ambr | 1 - tests/components/axis/snapshots/test_hub.ambr | 2 - .../cambridge_audio/snapshots/test_init.ambr | 1 - .../components/deconz/snapshots/test_hub.ambr | 1 - .../snapshots/test_init.ambr | 3 - .../ecovacs/snapshots/test_init.ambr | 1 - .../elgato/snapshots/test_button.ambr | 2 - .../elgato/snapshots/test_light.ambr | 3 - .../elgato/snapshots/test_sensor.ambr | 5 - .../elgato/snapshots/test_switch.ambr | 2 - .../snapshots/test_diagnostics.ambr | 8 - tests/components/flo/snapshots/test_init.ambr | 2 - .../snapshots/test_init.ambr | 1 - .../snapshots/test_init.ambr | 4 - .../components/homee/snapshots/test_init.ambr | 2 - .../snapshots/test_init.ambr | 116 --------- .../homekit_controller/test_init.py | 1 + .../homewizard/snapshots/test_button.ambr | 1 - .../homewizard/snapshots/test_number.ambr | 2 - .../homewizard/snapshots/test_select.ambr | 1 - .../homewizard/snapshots/test_sensor.ambr | 231 ------------------ .../homewizard/snapshots/test_switch.ambr | 11 - .../snapshots/test_init.ambr | 1 - .../snapshots/test_init.ambr | 1 - .../iotty/snapshots/test_switch.ambr | 1 - .../ista_ecotrend/snapshots/test_init.ambr | 2 - .../ituran/snapshots/test_init.ambr | 1 - .../kitchen_sink/snapshots/test_switch.ambr | 4 - .../lamarzocco/snapshots/test_init.ambr | 1 - .../lektrico/snapshots/test_init.ambr | 1 - .../mastodon/snapshots/test_init.ambr | 1 - .../mealie/snapshots/test_init.ambr | 1 - .../meater/snapshots/test_init.ambr | 1 - .../components/miele/snapshots/test_init.ambr | 1 - .../myuplink/snapshots/test_init.ambr | 3 - .../netatmo/snapshots/test_init.ambr | 39 --- .../netgear_lte/snapshots/test_init.ambr | 1 - .../nyt_games/snapshots/test_init.ambr | 3 - .../components/ohme/snapshots/test_init.ambr | 1 - .../ondilo_ico/snapshots/test_init.ambr | 2 - .../onedrive/snapshots/test_init.ambr | 1 - .../onewire/snapshots/test_init.ambr | 22 -- .../snapshots/test_init.ambr | 2 - .../overseerr/snapshots/test_init.ambr | 1 - .../palazzetti/snapshots/test_init.ambr | 1 - .../peblar/snapshots/test_init.ambr | 1 - .../rainforest_raven/snapshots/test_init.ambr | 1 - .../renault/snapshots/test_init.ambr | 5 - .../components/rova/snapshots/test_init.ambr | 1 - .../russound_rio/snapshots/test_init.ambr | 1 - .../samsungtv/snapshots/test_init.ambr | 3 - .../schlage/snapshots/test_init.ambr | 1 - .../sensibo/snapshots/test_entity.ambr | 4 - .../sfr_box/snapshots/test_binary_sensor.ambr | 2 - .../sfr_box/snapshots/test_button.ambr | 1 - .../sfr_box/snapshots/test_sensor.ambr | 1 - .../slide_local/snapshots/test_init.ambr | 1 - .../smartthings/snapshots/test_init.ambr | 68 ------ .../smarty/snapshots/test_init.ambr | 1 - .../smlight/snapshots/test_init.ambr | 1 - .../squeezebox/snapshots/test_init.ambr | 2 - .../snapshots/test_binary_sensor.ambr | 2 - .../tailwind/snapshots/test_button.ambr | 1 - .../tailwind/snapshots/test_cover.ambr | 2 - .../tailwind/snapshots/test_number.ambr | 1 - .../components/tedee/snapshots/test_init.ambr | 2 - .../components/tedee/snapshots/test_lock.ambr | 1 - .../tesla_fleet/snapshots/test_init.ambr | 4 - .../teslemetry/snapshots/test_init.ambr | 4 - .../components/tile/snapshots/test_init.ambr | 1 - .../tplink/snapshots/test_binary_sensor.ambr | 1 - .../tplink/snapshots/test_button.ambr | 1 - .../tplink/snapshots/test_camera.ambr | 1 - .../tplink/snapshots/test_climate.ambr | 1 - .../components/tplink/snapshots/test_fan.ambr | 1 - .../tplink/snapshots/test_number.ambr | 1 - .../tplink/snapshots/test_select.ambr | 1 - .../tplink/snapshots/test_sensor.ambr | 1 - .../tplink/snapshots/test_siren.ambr | 1 - .../tplink/snapshots/test_switch.ambr | 1 - .../tplink/snapshots/test_vacuum.ambr | 1 - .../components/tuya/snapshots/test_init.ambr | 1 - .../twentemilieu/snapshots/test_calendar.ambr | 1 - .../twentemilieu/snapshots/test_sensor.ambr | 5 - .../uptime/snapshots/test_sensor.ambr | 1 - .../velbus/snapshots/test_init.ambr | 10 - .../components/vesync/snapshots/test_fan.ambr | 12 - .../vesync/snapshots/test_light.ambr | 12 - .../vesync/snapshots/test_sensor.ambr | 12 - .../vesync/snapshots/test_switch.ambr | 12 - .../webostv/snapshots/test_media_player.ambr | 1 - .../whois/snapshots/test_sensor.ambr | 10 - .../withings/snapshots/test_init.ambr | 2 - .../wled/snapshots/test_button.ambr | 1 - .../wled/snapshots/test_number.ambr | 2 - .../wled/snapshots/test_select.ambr | 4 - .../wled/snapshots/test_switch.ambr | 4 - .../wmspro/snapshots/test_cover.ambr | 1 - .../wmspro/snapshots/test_init.ambr | 12 - .../wmspro/snapshots/test_light.ambr | 1 - .../wmspro/snapshots/test_scene.ambr | 1 - .../wolflink/snapshots/test_sensor.ambr | 1 - .../yale/snapshots/test_binary_sensor.ambr | 1 - .../components/yale/snapshots/test_lock.ambr | 1 - .../snapshots/test_entity_platform.ambr | 2 - tests/syrupy.py | 1 + 113 files changed, 3 insertions(+), 735 deletions(-) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 6487830675f..93244068feb 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -118,6 +118,7 @@ async def async_get_config_entry_diagnostics( device_dict.pop("_cache", None) # This can be removed when suggested_area is removed from DeviceEntry device_dict.pop("_suggested_area") + device_dict.pop("is_new", None) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index d518de056b2..9e311260693 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -21,7 +21,6 @@ 'aa:bb:cc:dd:ee:ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Acaia', diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 96ce43260aa..2a1e3dcc7fd 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '84fce612f5b8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'AirGradient', @@ -57,7 +56,6 @@ '84fce612f5b8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'AirGradient', diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index c396c65246a..bf28f8fb1a1 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'echo_test_serial_number', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Amazon', diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index f814106870b..c4c1b0b1b93 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -17,7 +17,6 @@ 'junctionId', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'A. O. Smith', diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 6ca412f7e34..414c3e451fd 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'XXXXXXXXXXXX', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -49,7 +48,6 @@ 'XXXX', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -81,7 +79,6 @@ 'mocked-config-entry-id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', @@ -113,7 +110,6 @@ 'mocked-config-entry-id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'APC', diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index c9a7b7ba039..9d94ae9ffdc 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -17,7 +17,6 @@ 'tmt100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'August Home Inc.', diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index eb2cf7a815a..8af45cae68c 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -21,7 +21,6 @@ 'online_with_doorsense', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'August Home Inc.', diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index ab4745011dd..663c52dd36c 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -21,7 +21,6 @@ '00:40:8c:12:34:56', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Axis Communications AB', @@ -57,7 +56,6 @@ '00:40:8c:12:34:56', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Axis Communications AB', diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 71a54cdb001..22642635375 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0020c2d8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Cambridge Audio', diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index b171dafbd5d..59e77c4fb12 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -17,7 +17,6 @@ '01234E56789A', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Dresden Elektronik', diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 4f965ce8d05..13603beb8b4 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', @@ -57,7 +56,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', @@ -89,7 +87,6 @@ '1234567890', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'devolo', diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 642f0db6813..0e847da73ad 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'E1234567890000000001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ecovacs', diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 5ff3710dfd7..85f9fadd2a0 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -70,7 +70,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -155,7 +154,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 8ee893f6be5..5dbc21f62df 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -102,7 +102,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -221,7 +220,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -340,7 +338,6 @@ 'CN11A1A00001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index ebf98ff02ae..f53f8d223bd 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -77,7 +77,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -172,7 +171,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -267,7 +265,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -359,7 +356,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -454,7 +450,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 8c75ed137b1..61235f17ece 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -69,7 +69,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', @@ -153,7 +152,6 @@ 'GW24L1A02987', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Elgato', diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index be638168b34..ca6c502d3be 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -50,7 +50,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -297,7 +296,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -927,7 +925,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -1174,7 +1171,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -1848,7 +1844,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -2095,7 +2090,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -2790,7 +2784,6 @@ '1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', @@ -3339,7 +3332,6 @@ '<>', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Enphase', diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index 51e7bbd6dce..6a242c4d2ce 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -22,7 +22,6 @@ '98765', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Flo by Moen', @@ -56,7 +55,6 @@ '32839', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Flo by Moen', diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index c26d39a5e25..e11d42d970e 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '00000000-0000-0000-0000-000000000001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f11791b8ed1..d0b92a7e88d 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -18,7 +18,6 @@ 'ulid-conversation', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Google', @@ -48,7 +47,6 @@ 'ulid-stt', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Google', @@ -78,7 +76,6 @@ 'ulid-ai-task', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Google', @@ -108,7 +105,6 @@ 'ulid-tts', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Google', diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr index dc56290e93e..8f20bb10454 100644 --- a/tests/components/homee/snapshots/test_init.ambr +++ b/tests/components/homee/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '00055511EECC', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'homee', @@ -53,7 +52,6 @@ '00055511EECC-3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 556be38f702..3b075b44356 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -24,7 +24,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Sleekpoint Innovations', @@ -654,7 +653,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -735,7 +733,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -992,7 +989,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -1249,7 +1245,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Anker', @@ -1510,7 +1505,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -1730,7 +1724,6 @@ '00:00:00:00:00:00:aid:33', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -1906,7 +1899,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -2197,7 +2189,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Aqara', @@ -2330,7 +2321,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netgear, Inc', @@ -2843,7 +2833,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ConnectSense', @@ -3314,7 +3303,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -3488,7 +3476,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -3969,7 +3956,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4143,7 +4129,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4321,7 +4306,6 @@ '00:00:00:00:00:00:aid:4295608960', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4586,7 +4570,6 @@ '00:00:00:00:00:00:aid:4298360914', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -4844,7 +4827,6 @@ '00:00:00:00:00:00:aid:4298360921', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5102,7 +5084,6 @@ '00:00:00:00:00:00:aid:4298527970', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5360,7 +5341,6 @@ '00:00:00:00:00:00:aid:4298527962', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5618,7 +5598,6 @@ '00:00:00:00:00:00:aid:4295016858', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -5883,7 +5862,6 @@ '00:00:00:00:00:00:aid:4298360712', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6141,7 +6119,6 @@ '00:00:00:00:00:00:aid:4298649931', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6399,7 +6376,6 @@ '00:00:00:00:00:00:aid:4295608971', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6664,7 +6640,6 @@ '00:00:00:00:00:00:aid:4298584118', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -6922,7 +6897,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7321,7 +7295,6 @@ '00:00:00:00:00:00:aid:4295016969', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7586,7 +7559,6 @@ '00:00:00:00:00:00:aid:4298568508', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -7848,7 +7820,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8333,7 +8304,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8457,7 +8427,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8757,7 +8726,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -8931,7 +8899,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -9109,7 +9076,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -9603,7 +9569,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'ecobee Inc.', @@ -9913,7 +9878,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Elgato', @@ -10295,7 +10259,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Elgato', @@ -10665,7 +10628,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'José A. Jiménez Campos', @@ -10886,7 +10848,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'José A. Jiménez Campos', @@ -11013,7 +10974,6 @@ '00:00:00:00:00:00:aid:123016423', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -11186,7 +11146,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11267,7 +11226,6 @@ '00:00:00:00:00:00:aid:878448248', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -11444,7 +11402,6 @@ '00:00:00:00:00:00:aid:766313939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11576,7 +11533,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11657,7 +11613,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -11794,7 +11749,6 @@ '00:00:00:00:00:00:aid:1233851541', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lookin', @@ -12141,7 +12095,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12226,7 +12179,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12307,7 +12259,6 @@ '00:00:00:00:00:00:aid:3982136094', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'FirstAlert', @@ -12492,7 +12443,6 @@ '00:00:00:00:00:00:aid:123016423', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -12665,7 +12615,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -12746,7 +12695,6 @@ '00:00:00:00:00:00:aid:878448248', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -12923,7 +12871,6 @@ '00:00:00:00:00:00:aid:766313939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13055,7 +13002,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13136,7 +13082,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13274,7 +13219,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13355,7 +13299,6 @@ '00:00:00:00:00:00:aid:1256851357', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13493,7 +13436,6 @@ '00:00:00:00:00:00:aid:1233851541', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lookin', @@ -13849,7 +13791,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -13934,7 +13875,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14015,7 +13955,6 @@ '00:00:00:00:00:00:aid:293334836', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'switchbot', @@ -14207,7 +14146,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14288,7 +14226,6 @@ '00:00:00:00:00:00:aid:293334836', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'switchbot', @@ -14480,7 +14417,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Home Assistant', @@ -14561,7 +14497,6 @@ '00:00:00:00:00:00:aid:3982136094', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'FirstAlert', @@ -14761,7 +14696,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Garzola Marco', @@ -14974,7 +14908,6 @@ '00:00:00:00:00:00:aid:6623462395276914', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15120,7 +15053,6 @@ '00:00:00:00:00:00:aid:6623462395276939', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15266,7 +15198,6 @@ '00:00:00:00:00:00:aid:6623462403113447', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15412,7 +15343,6 @@ '00:00:00:00:00:00:aid:6623462403233419', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15558,7 +15488,6 @@ '00:00:00:00:00:00:aid:6623462412411853', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15714,7 +15643,6 @@ '00:00:00:00:00:00:aid:6623462412413293', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -15870,7 +15798,6 @@ '00:00:00:00:00:00:aid:6623462389072572', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16203,7 +16130,6 @@ '00:00:00:00:00:00:aid:6623462378982941', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16336,7 +16262,6 @@ '00:00:00:00:00:00:aid:6623462378983942', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16469,7 +16394,6 @@ '00:00:00:00:00:00:aid:6623462379122122', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16602,7 +16526,6 @@ '00:00:00:00:00:00:aid:6623462379123707', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16735,7 +16658,6 @@ '00:00:00:00:00:00:aid:6623462383114163', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -16868,7 +16790,6 @@ '00:00:00:00:00:00:aid:6623462383114193', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -17001,7 +16922,6 @@ '00:00:00:00:00:00:aid:6623462385996792', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips', @@ -17134,7 +17054,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Philips Lighting', @@ -17219,7 +17138,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17371,7 +17289,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17549,7 +17466,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Koogeek', @@ -17768,7 +17684,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lennox', @@ -18067,7 +17982,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'LG Electronics', @@ -18258,7 +18172,6 @@ '00:00:00:00:00:00:aid:21474836482', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lutron Electronics Co., Inc', @@ -18390,7 +18303,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Lutron Electronics Co., Inc', @@ -18475,7 +18387,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Meross', @@ -18770,7 +18681,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Meross', @@ -18907,7 +18817,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Empowered Homes Inc.', @@ -19256,7 +19165,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Nanoleaf', @@ -19540,7 +19448,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -19850,7 +19757,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -20021,7 +19927,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -20346,7 +20251,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Green Electronics LLC', @@ -20791,7 +20695,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -20964,7 +20867,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21045,7 +20947,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21222,7 +21123,6 @@ '00:00:00:00:00:00:aid:4', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21395,7 +21295,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21568,7 +21467,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21741,7 +21639,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21822,7 +21719,6 @@ '00:00:00:00:00:00:aid:5', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'RYSE Inc.', @@ -21999,7 +21895,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Schlage ', @@ -22127,7 +22022,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Hunter Fan', @@ -22316,7 +22210,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -22446,7 +22339,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Moen Incorporated', @@ -22860,7 +22752,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -23089,7 +22980,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23170,7 +23060,6 @@ '00:00:00:00:00:00:aid:2', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23395,7 +23284,6 @@ '00:00:00:00:00:00:aid:3', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VELUX', @@ -23525,7 +23413,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -23655,7 +23542,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'Netatmo', @@ -23784,7 +23670,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VOCOlinc', @@ -24104,7 +23989,6 @@ '00:00:00:00:00:00:aid:1', ]), ]), - 'is_new': False, 'labels': list([ ]), 'manufacturer': 'VOCOlinc', diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 166fd1a9e65..86c428b4413 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -330,6 +330,7 @@ async def test_snapshots( device_dict.pop("_cache", None) # This can be removed when suggested_area is removed from DeviceEntry device_dict.pop("_suggested_area") + device_dict.pop("is_new") devices.append({"device": device_dict, "entities": entities}) diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index 3b6264367e2..967672580ec 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -70,7 +70,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index b75b89269f1..972b7fc5728 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -79,7 +79,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -173,7 +172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index dd331c3f49b..0797256120c 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -80,7 +80,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index f870170bae9..1bde08b3201 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -21,7 +21,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -108,7 +107,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -200,7 +198,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -292,7 +289,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -384,7 +380,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -476,7 +471,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -568,7 +562,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -660,7 +653,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -745,7 +737,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -837,7 +828,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -925,7 +915,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1009,7 +998,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1101,7 +1089,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1193,7 +1180,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1285,7 +1271,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1377,7 +1362,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1469,7 +1453,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1561,7 +1544,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1650,7 +1632,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1742,7 +1723,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1834,7 +1814,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -1918,7 +1897,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2006,7 +1984,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2098,7 +2075,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2190,7 +2166,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2282,7 +2257,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2374,7 +2348,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2466,7 +2439,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2558,7 +2530,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2650,7 +2621,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2742,7 +2712,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2834,7 +2803,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -2926,7 +2894,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3018,7 +2985,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3110,7 +3076,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3199,7 +3164,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3288,7 +3252,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3377,7 +3340,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3469,7 +3431,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3561,7 +3522,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3653,7 +3613,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3745,7 +3704,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3837,7 +3795,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -3929,7 +3886,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4021,7 +3977,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4113,7 +4068,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4205,7 +4159,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4297,7 +4250,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4381,7 +4333,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4469,7 +4420,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4558,7 +4508,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4650,7 +4599,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4742,7 +4690,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4834,7 +4781,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -4918,7 +4864,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5010,7 +4955,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5102,7 +5046,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5194,7 +5137,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5286,7 +5228,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5378,7 +5319,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5470,7 +5410,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5562,7 +5501,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5654,7 +5592,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5746,7 +5683,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5838,7 +5774,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -5930,7 +5865,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6014,7 +5948,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6103,7 +6036,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6195,7 +6127,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6279,7 +6210,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6371,7 +6301,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6463,7 +6392,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6555,7 +6483,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6639,7 +6566,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6723,7 +6649,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6821,7 +6746,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -6913,7 +6837,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7005,7 +6928,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7097,7 +7019,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7189,7 +7110,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7273,7 +7193,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7357,7 +7276,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7441,7 +7359,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7525,7 +7442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7609,7 +7525,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7693,7 +7608,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7781,7 +7695,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7865,7 +7778,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -7949,7 +7861,6 @@ 'gas_meter_G001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8037,7 +7948,6 @@ 'heat_meter_H001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8125,7 +8035,6 @@ 'inlet_heat_meter_IH001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8209,7 +8118,6 @@ 'warm_water_meter_WW001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8297,7 +8205,6 @@ 'water_meter_W001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8389,7 +8296,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8478,7 +8384,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8570,7 +8475,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8662,7 +8566,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8754,7 +8657,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8838,7 +8740,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -8930,7 +8831,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9022,7 +8922,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9114,7 +9013,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9206,7 +9104,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9298,7 +9195,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9390,7 +9286,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9482,7 +9377,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9574,7 +9468,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9666,7 +9559,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9758,7 +9650,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9850,7 +9741,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -9934,7 +9824,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10023,7 +9912,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10115,7 +10003,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10199,7 +10086,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10291,7 +10177,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10383,7 +10268,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10475,7 +10359,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10559,7 +10442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10643,7 +10525,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10741,7 +10622,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10833,7 +10713,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -10925,7 +10804,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11017,7 +10895,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11109,7 +10986,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11193,7 +11069,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11277,7 +11152,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11361,7 +11235,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11445,7 +11318,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11529,7 +11401,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11613,7 +11484,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11701,7 +11571,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11785,7 +11654,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11869,7 +11737,6 @@ 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -11957,7 +11824,6 @@ 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12045,7 +11911,6 @@ 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12129,7 +11994,6 @@ 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12217,7 +12081,6 @@ 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12309,7 +12172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12398,7 +12260,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12490,7 +12351,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12582,7 +12442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12674,7 +12533,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12766,7 +12624,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12858,7 +12715,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -12950,7 +12806,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13042,7 +12897,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13134,7 +12988,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13226,7 +13079,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13318,7 +13170,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13410,7 +13261,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13502,7 +13352,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13594,7 +13443,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13686,7 +13534,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13770,7 +13617,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13862,7 +13708,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -13946,7 +13791,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14038,7 +13882,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14130,7 +13973,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14222,7 +14064,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14314,7 +14155,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14406,7 +14246,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14498,7 +14337,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14590,7 +14428,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14674,7 +14511,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14758,7 +14594,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14842,7 +14677,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -14926,7 +14760,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15010,7 +14843,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15094,7 +14926,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15182,7 +15013,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15266,7 +15096,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15354,7 +15183,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15446,7 +15274,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15538,7 +15365,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15630,7 +15456,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15722,7 +15547,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15806,7 +15630,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15894,7 +15717,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -15986,7 +15808,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16078,7 +15899,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16170,7 +15990,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16262,7 +16081,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16354,7 +16172,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16446,7 +16263,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16535,7 +16351,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16627,7 +16442,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16719,7 +16533,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16811,7 +16624,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16895,7 +16707,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -16983,7 +16794,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17075,7 +16885,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17163,7 +16972,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17247,7 +17055,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17335,7 +17142,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17427,7 +17233,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17519,7 +17324,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17611,7 +17415,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17703,7 +17506,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17795,7 +17597,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17887,7 +17688,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -17976,7 +17776,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18068,7 +17867,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18160,7 +17958,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18244,7 +18041,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18332,7 +18128,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18424,7 +18219,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18516,7 +18310,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18608,7 +18401,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18700,7 +18492,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18792,7 +18583,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18884,7 +18674,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -18976,7 +18765,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19068,7 +18856,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19160,7 +18947,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19252,7 +19038,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19344,7 +19129,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19436,7 +19220,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19525,7 +19308,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19614,7 +19396,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19703,7 +19484,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19795,7 +19575,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19887,7 +19666,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -19979,7 +19757,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20071,7 +19848,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20163,7 +19939,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20255,7 +20030,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20347,7 +20121,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20439,7 +20212,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20531,7 +20303,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20623,7 +20394,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -20707,7 +20477,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 49916a59d9e..d61979c84b5 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -69,7 +69,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -153,7 +152,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -238,7 +236,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -322,7 +319,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -406,7 +402,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -491,7 +486,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -575,7 +569,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -659,7 +652,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -743,7 +735,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -827,7 +818,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', @@ -911,7 +901,6 @@ '5c2fafabcdef', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'HomeWizard', diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index e0627ad9da8..82116391f4f 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'c7233734-b219-4287-a173-08e3643f89f0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Husqvarna', diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index e2b8eeba811..6b4ab8236f9 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '00000000-0000-0000-0000-000000000003_1197489078', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Husqvarna', diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 04712dbf022..41e79911154 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -29,7 +29,6 @@ 'TestLS', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'iotty', diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 6a5f5371a9d..02076bf5597 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '26e93f1a-c828-11ea-87d0-0242ac130003', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ista SE', @@ -49,7 +48,6 @@ 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ista SE', diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 456687407e2..5fb786029b4 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -18,7 +18,6 @@ '12345678', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'mock make', diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index 350ac169938..2bee2f1f61c 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -65,7 +65,6 @@ 'outlet_1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -97,7 +96,6 @@ '2_ch_power_strip', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -177,7 +175,6 @@ 'outlet_2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -209,7 +206,6 @@ '2_ch_power_strip', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index f11057f8620..bdebd35d6dd 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -25,7 +25,6 @@ 'GS012345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'La Marzocco', diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index a935f5cfa14..e1b5a48fe27 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '500006', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Lektrico', diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 4d3e9d7aeab..662ffd51cb4 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'trwnh_mastodon_social', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Mastodon gGmbH', diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index e3a9e608911..50da06ca005 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'bf1c62fe-4941-4332-9886-e54e88dbdba0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 95335942de6..654e631cdda 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Apption Labs', diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index 9feeeb6523b..81f6c0c3a35 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'Dummy_Appliance_1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Miele', diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 56fb26b4084..66b4c9efe35 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Jäspi', @@ -49,7 +48,6 @@ 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Nibe', @@ -81,7 +79,6 @@ 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Nibe', diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 95fb1f9ed45..3f8d924bdbf 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0009999992', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Bubbendorf', @@ -49,7 +48,6 @@ '0009999993', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Bubbendorf', @@ -81,7 +79,6 @@ '00:11:22:33:00:11:45:fe', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -113,7 +110,6 @@ '1002003001', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Smarther', @@ -145,7 +141,6 @@ '12:34:56:00:00:a1:4c:da', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -177,7 +172,6 @@ '12:34:56:00:01:01:01:a1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -209,7 +203,6 @@ '12:34:56:00:16:0e#0', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -241,7 +234,6 @@ '12:34:56:00:16:0e#1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -273,7 +265,6 @@ '12:34:56:00:16:0e#2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -305,7 +296,6 @@ '12:34:56:00:16:0e#3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -337,7 +327,6 @@ '12:34:56:00:16:0e#4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -369,7 +358,6 @@ '12:34:56:00:16:0e#5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -401,7 +389,6 @@ '12:34:56:00:16:0e#6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -433,7 +420,6 @@ '12:34:56:00:16:0e#7', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -465,7 +451,6 @@ '12:34:56:00:16:0e#8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -497,7 +482,6 @@ '12:34:56:00:16:0e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -529,7 +513,6 @@ '12:34:56:00:f1:62', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -561,7 +544,6 @@ '12:34:56:03:1b:e4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -593,7 +575,6 @@ '12:34:56:10:b9:0e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -625,7 +606,6 @@ '12:34:56:10:f1:66', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -657,7 +637,6 @@ '12:34:56:25:cf:a8', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -689,7 +668,6 @@ '12:34:56:26:65:14', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -721,7 +699,6 @@ '12:34:56:26:68:92', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -753,7 +730,6 @@ '12:34:56:26:69:0c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -785,7 +761,6 @@ '12:34:56:3e:c5:46', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -817,7 +792,6 @@ '12:34:56:80:00:12:ac:f2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Legrand', @@ -849,7 +823,6 @@ '12:34:56:80:1c:42', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -881,7 +854,6 @@ '12:34:56:80:44:92', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -913,7 +885,6 @@ '12:34:56:80:7e:18', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -945,7 +916,6 @@ '12:34:56:80:bb:26', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -977,7 +947,6 @@ '12:34:56:80:c1:ea', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1009,7 +978,6 @@ '222452125', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1041,7 +1009,6 @@ '2746182631', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1073,7 +1040,6 @@ '2833524037', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1105,7 +1071,6 @@ '2940411577', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1137,7 +1102,6 @@ '91763b24c43d3e344f424e8b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1169,7 +1133,6 @@ 'Home avg', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1201,7 +1164,6 @@ 'Home max', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', @@ -1233,7 +1195,6 @@ 'Home min', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netatmo', diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index 2980e3f35f0..fd58e6e0002 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'FFFFFFFFFFFFF', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Netgear', diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index 5ca9a2d8df2..f920b064f0b 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '218886794_connections', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', @@ -49,7 +48,6 @@ '218886794_spelling_bee', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', @@ -81,7 +79,6 @@ '218886794_wordle', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'New York Times', diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index 2e8304489d9..dc49f5f4042 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'chargerid', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ohme', diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 787551ad90e..6ea2ad11103 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'W1122333044455', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ondilo', @@ -49,7 +48,6 @@ 'W2233304445566', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ondilo', diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 2f9cfc1a038..2573c34e1fa 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'mock_drive_id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Microsoft', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 26ed15fc897..b879541d4ca 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '05.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -49,7 +48,6 @@ '10.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -81,7 +79,6 @@ '12.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -113,7 +110,6 @@ '1D.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -145,7 +141,6 @@ '1F.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -177,7 +172,6 @@ '20.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -209,7 +203,6 @@ '22.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -241,7 +234,6 @@ '26.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -273,7 +265,6 @@ '28.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -305,7 +296,6 @@ '28.222222222222', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -337,7 +327,6 @@ '28.222222222223', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -369,7 +358,6 @@ '29.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -401,7 +389,6 @@ '30.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -433,7 +420,6 @@ '3A.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -465,7 +451,6 @@ '3B.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -497,7 +482,6 @@ '42.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -529,7 +513,6 @@ '7E.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Embedded Data Systems', @@ -561,7 +544,6 @@ '7E.222222222222', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Embedded Data Systems', @@ -593,7 +575,6 @@ 'A6.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Maxim Integrated', @@ -625,7 +606,6 @@ 'EF.111111111111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', @@ -657,7 +637,6 @@ 'EF.111111111112', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', @@ -689,7 +668,6 @@ 'EF.111111111113', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Hobby Boards', diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index 0058416b254..f5006ac979f 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -11,7 +11,6 @@ 'entry_type': , 'hw_version': None, 'id': , - 'is_new': False, 'labels': set({ }), 'manufacturer': 'OpenAI', @@ -37,7 +36,6 @@ 'entry_type': , 'hw_version': None, 'id': , - 'is_new': False, 'labels': set({ }), 'manufacturer': 'OpenAI', diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 71c1b9ffd3a..f861ccaa9ed 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '01JG00V55WEVTJ0CJHM0GAD7PC', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index b69982d9c08..3fca1d851ce 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Palazzetti', diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 97c0737e402..21edc32c629 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -25,7 +25,6 @@ '23-45-A4O-MOF', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Peblar', diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index f34d33d6c24..9cc89cfcc9e 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -22,7 +22,6 @@ 'abcdef0123456789', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Rainforest Automation, Inc.', diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index defb0f249ff..15b3c599711 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -18,7 +18,6 @@ 'VF1CAPTURFUELVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -52,7 +51,6 @@ 'VF1CAPTURPHEVVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -86,7 +84,6 @@ 'VF1TWINGOIIIVIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -120,7 +117,6 @@ 'VF1ZOE40VIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', @@ -154,7 +150,6 @@ 'VF1ZOE50VIN', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Renault', diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 3715f994fb0..25925ac3865 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '8381BE13', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index 0fcebb8a6e5..b02f80f1dfd 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -21,7 +21,6 @@ '00:11:22:33:44:55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Russound', diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index f9006c7fd52..4be166ecf25 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -22,7 +22,6 @@ 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -56,7 +55,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -94,7 +92,6 @@ 'be9554b9-c9fb-41f4-8920-22da015376a4', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index 4e57ad5d5c6..1b6cc3f1cdb 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'test', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Schlage', diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index ee0b3835da4..ba075d764f5 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -22,7 +22,6 @@ 'ABC999111', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -56,7 +55,6 @@ 'AAZZAAZZ', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -90,7 +88,6 @@ 'BBZZBBZZ', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -120,7 +117,6 @@ 'AABBCC', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index f0193b6ce1c..f046f95ed42 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -150,7 +149,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index e3e5475ca34..5d5c6d0edba 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 681c3a84191..0440505859a 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -18,7 +18,6 @@ 'e4:5d:51:00:11:22', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index e2dec748e2a..cc93a49b98a 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Innovation in Motion', diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index d63ac4e9ab4..17a9d6691cc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -20,7 +20,6 @@ '7c16163e-c94e-482f-95f6-139ae0cd9d5e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -52,7 +51,6 @@ 'f0af21a2-d5a1-437c-b10a-b34a87394b71', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -84,7 +82,6 @@ 'bf53a150-f8a4-45d1-aac4-86252475d551', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -116,7 +113,6 @@ '68e786a6-7f61-4c3a-9e13-70b803cf782b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -148,7 +144,6 @@ '286ba274-4093-4bcb-849c-a1a3efe7b1e5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -180,7 +175,6 @@ '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Arlo', @@ -212,7 +206,6 @@ '571af102-15db-4030-b76b-245a691f74a5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WonderLabs Company', @@ -244,7 +237,6 @@ 'd0268a69-abfb-4c92-a646-61cec2e510ad', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -276,7 +268,6 @@ '2d9a892b-1c93-45a5-84cb-0e81889498c6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -308,7 +299,6 @@ 'a3a970ea-e09c-9c04-161b-94c934e21666', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -340,7 +330,6 @@ '4165c51e-bf6b-c5b6-fd53-127d6248754b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -372,7 +361,6 @@ '96a5ef74-5832-a84b-f1f7-ca799957065d', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -404,7 +392,6 @@ 'c76d6f38-1b7f-13dd-37b5-db18d5272783', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -436,7 +423,6 @@ '4ece486b-89db-f06a-d54d-748b676b4d8e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -468,7 +454,6 @@ 'F8042E25-0E53-0000-0000-000000000000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -500,7 +485,6 @@ '808dbd84-f357-47e2-a0cd-3b66fa22d584', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -532,7 +516,6 @@ '2bad3237-4886-e699-1b90-4a51a3d55c8a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -564,7 +547,6 @@ '9447959a-0dfa-6b27-d40d-650da525c53f', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -596,7 +578,6 @@ '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -628,7 +609,6 @@ '7db87911-7dce-1cf2-7119-b953432a2f09', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -660,7 +640,6 @@ '7d3feb98-8a36-4351-c362-5e21ad3a78dd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -692,7 +671,6 @@ '5758b2ec-563e-f39b-ec39-208e54aabf60', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -724,7 +702,6 @@ '05accb39-2017-c98b-a5ab-04a81f4d3d9a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -756,7 +733,6 @@ '3442dfc6-17c0-a65f-dae0-4c6e01786f44', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -788,7 +764,6 @@ '1f98ebd0-ac48-d802-7f62-000001200100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -820,7 +795,6 @@ '6a7d5349-0a66-0277-058d-000001200101', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -852,7 +826,6 @@ '3810e5ad-5351-d9f9-12ff-000001200000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -884,7 +857,6 @@ 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -916,7 +888,6 @@ 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -948,7 +919,6 @@ '02f7256e-8353-5bdd-547f-bd5b1647e01b', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -980,7 +950,6 @@ '3a6c4e05-811d-5041-e956-3d04c424cbcd', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1012,7 +981,6 @@ 'f984b91d-f250-9d42-3436-33f09a422a47', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1044,7 +1012,6 @@ '63803fae-cbed-f356-a063-2cf148ae3ca7', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1076,7 +1043,6 @@ 'b854ca5f-dc54-140d-6349-758b4d973c41', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1108,7 +1074,6 @@ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1140,7 +1105,6 @@ 'd5dc3299-c266-41c7-bd08-f540aea54b89', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1172,7 +1136,6 @@ '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1204,7 +1167,6 @@ '1888b38f-6246-4f1e-911b-bfcfb66999db', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'ecobee', @@ -1236,7 +1198,6 @@ 'f1af21a2-d5a1-437c-b10a-b34a87394b71', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1268,7 +1229,6 @@ '3b57dca3-9a90-4f27-ba80-f947b1e60d58', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'CopperLabs', @@ -1300,7 +1260,6 @@ 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1332,7 +1291,6 @@ '656569c2-7976-4232-a789-34b4d1176c3a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1364,7 +1322,6 @@ '6d95a8b7-4ee3-429a-a13a-00ec9354170c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1396,7 +1353,6 @@ '5e5b97f3-3094-44e6-abc0-f61283412d6a', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1428,7 +1384,6 @@ '69a271f6-6537-4982-8cd9-979866872692', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1460,7 +1415,6 @@ '440063de-a200-40b5-8a6b-f3399eaa0370', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Signify Netherlands B.V.', @@ -1492,7 +1446,6 @@ 'cb958955-b015-498c-9e62-fc0c51abd054', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Signify Netherlands B.V.', @@ -1524,7 +1477,6 @@ 'afcf3b91-0000-1111-2222-ddff2a0a6577', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1556,7 +1508,6 @@ '71afed1c-006d-4e48-b16e-e7f88f9fd638', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1588,7 +1539,6 @@ '83d660e4-b0c8-4881-a674-d9f1730366c1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1620,7 +1570,6 @@ 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1652,7 +1601,6 @@ '184c67cc-69e2-44b6-8f73-55c963068ad9', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1684,7 +1632,6 @@ '692ea4e9-2022-4ed8-8a57-1b884a59cc38', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1716,7 +1663,6 @@ '7d246592-93db-4d72-a10d-5a51793ece8c', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1748,7 +1694,6 @@ '2409a73c-918a-4d1f-b4f5-c27468c71d70', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Emerson', @@ -1780,7 +1725,6 @@ 'bf4b1167-48a3-4af7-9186-0900a678ffa5', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Sensibo', @@ -1812,7 +1756,6 @@ '550a1c72-65a0-4d55-b97b-75168e055398', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1844,7 +1787,6 @@ 'c85fced9-c474-4a47-93c2-037cc7829536', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -1876,7 +1818,6 @@ '6602696a-1e48-49e4-919f-69406f5b5da1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', @@ -1908,7 +1849,6 @@ '0d94e5db-8501-2355-eb4f-214163702cac', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1940,7 +1880,6 @@ 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -1972,7 +1911,6 @@ '5cc1c096-98b9-460c-8f1c-1045509ec605', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -2004,7 +1942,6 @@ '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Samsung Electronics', @@ -2036,7 +1973,6 @@ '2894dc93-0f11-49cc-8a81-3a684cebebf6', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2068,7 +2004,6 @@ '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2100,7 +2035,6 @@ 'a2a6018b-2663-4727-9d1d-8f56953b5116', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2132,7 +2066,6 @@ 'a9f587c5-5d8b-4273-8907-e7f609af5158', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -2168,7 +2101,6 @@ '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index ffa30051726..989e95bde7e 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '01JAZ5DPW8C62D620DGYNG2R8H', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Salda', diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 8f533a42e36..7f46daef13c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -17,7 +17,6 @@ 'id': , 'identifiers': set({ }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'SMLIGHT', diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index 39664f9ecf2..afd90d026de 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -21,7 +21,6 @@ 'aa:bb:cc:dd:ee:ff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Ralph Irving & Adrian Smith', @@ -61,7 +60,6 @@ 'ff:ee:dd:cc:bb:aa', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index a5a591af94c..2cf93435bbf 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -66,7 +66,6 @@ '_3c_e9_e_6d_21_84_-door1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -147,7 +146,6 @@ '_3c_e9_e_6d_21_84_-door2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 627f05432d2..12b99997db0 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -70,7 +70,6 @@ '_3c_e9_e_6d_21_84_', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index 54c648ba21b..a14dcfc44f1 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -67,7 +67,6 @@ '_3c_e9_e_6d_21_84_-door1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', @@ -149,7 +148,6 @@ '_3c_e9_e_6d_21_84_-door2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index acabe061420..f9132530cee 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -79,7 +79,6 @@ '_3c_e9_e_6d_21_84_', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tailwind', diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index cfaca7b81f3..38874d08f3a 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '0000-0000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', @@ -49,7 +48,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index b7bf9e6bfa5..a73e5c746aa 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -66,7 +66,6 @@ '98765', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tedee', diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index a669813a3a5..7ce99965900 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -49,7 +48,6 @@ 'LRWXF7EK4KC700000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -81,7 +79,6 @@ 'abd-123', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -113,7 +110,6 @@ 'bcd-234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 39fc8d04984..ee27bc9f0af 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '123456', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -49,7 +48,6 @@ 'LRW3F7EK4NC700000', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -81,7 +79,6 @@ 'abd-123', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', @@ -113,7 +110,6 @@ 'bcd-234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tesla', diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 0c3e1faf090..9e2620313a0 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '19264d2dffdbca32', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tile Inc.', diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index c12f73bd737..ed5f935f286 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -420,7 +420,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 7d49d2aedbc..37cfb4a36c0 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -602,7 +602,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index a0282401e58..b17b30bbbb4 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -72,7 +72,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index 4a38bdbbe59..01738bff943 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -82,7 +82,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index eb42e2a7298..b48ef8d336b 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -186,7 +186,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index bc71313bf96..da4dc26317b 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index 6bcd24521e4..1db6e3cf57d 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index f95390a8a57..05d645552bb 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index 7f90915f624..45bad203bc9 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 98584c79759..6d1d2fa3bad 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index e5b28f5ac7a..e3a7f1d95fe 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -21,7 +21,6 @@ '123456789ABCDEFGH', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'TP-Link', diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index fc30460bcc0..127c764ea0d 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -18,7 +18,6 @@ 'mock_device_id', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Tuya', diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 68f5a7b6adf..15fcd7cee09 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -97,7 +97,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index ad435a833ee..3b4e21be1e1 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -66,7 +66,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -147,7 +146,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -228,7 +226,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -309,7 +306,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', @@ -390,7 +386,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Twente Milieu', diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index cb4563e0fb5..c57f2987c5b 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -59,7 +59,6 @@ 'entry_type': , 'hw_version': None, 'id': , - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 29f92126f95..0383abc0313 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -18,7 +18,6 @@ '1', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -48,7 +47,6 @@ '2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -78,7 +76,6 @@ '88', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -108,7 +105,6 @@ '88-10', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -138,7 +134,6 @@ '88-11', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -168,7 +163,6 @@ '88-2', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -198,7 +192,6 @@ '88-3', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -228,7 +221,6 @@ '88-33', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -258,7 +250,6 @@ '88-55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', @@ -288,7 +279,6 @@ '88-9', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Velleman', diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 212535862f5..86cfa8198ba 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -118,7 +117,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -217,7 +215,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -318,7 +315,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -419,7 +415,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -457,7 +452,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -495,7 +489,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -533,7 +526,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -571,7 +563,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -609,7 +600,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -714,7 +704,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -752,7 +741,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index eac595cc0e9..df2dad8825d 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -56,7 +55,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -94,7 +92,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -132,7 +129,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -170,7 +166,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -262,7 +257,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -356,7 +350,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -394,7 +387,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -432,7 +424,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -470,7 +461,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -508,7 +498,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -615,7 +604,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 6aa25e0763a..143520b68c2 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -152,7 +151,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -240,7 +238,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -425,7 +422,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -610,7 +606,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -648,7 +643,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -686,7 +680,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -775,7 +768,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -864,7 +856,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1226,7 +1217,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1264,7 +1254,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -1302,7 +1291,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 8947ac40424..e7917397063 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -18,7 +18,6 @@ 'air-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -102,7 +101,6 @@ 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -186,7 +184,6 @@ '400s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -270,7 +267,6 @@ '600s-purifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -354,7 +350,6 @@ 'dimmable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -392,7 +387,6 @@ 'dimmable-switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -430,7 +424,6 @@ '200s-humidifier4321', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -514,7 +507,6 @@ '600s-humidifier', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -598,7 +590,6 @@ 'outlet', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -683,7 +674,6 @@ 'smarttowerfan', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -767,7 +757,6 @@ 'tunable-bulb', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', @@ -805,7 +794,6 @@ 'switch', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'VeSync', diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index d0a1142618a..7c0bdfb0d13 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -53,7 +53,6 @@ 'some-fake-uuid', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'LG', diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 38f125ad712..30cdb9080f8 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -65,7 +65,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -146,7 +145,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -231,7 +229,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -312,7 +309,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -393,7 +389,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -473,7 +468,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -553,7 +547,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -633,7 +626,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -713,7 +705,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, @@ -845,7 +836,6 @@ 'home-assistant.io', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': None, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index 88d9ff94b1d..31c23987680 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '12345', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Withings', @@ -49,7 +48,6 @@ 'f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Withings', diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index 26f8817fa06..b7bb8a1eea1 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -70,7 +70,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 5503b9a733d..8f94c270984 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -78,7 +78,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -171,7 +170,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index dc8a2f09445..a981b741852 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -80,7 +80,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -311,7 +310,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -404,7 +402,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -497,7 +494,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 09c86d81d44..43e91f7b485 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -71,7 +71,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -155,7 +154,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -240,7 +238,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', @@ -325,7 +322,6 @@ 'aabbccddeeff', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WLED', diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 026785c9e1c..8590c4ba725 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -17,7 +17,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 9d60cf8c907..ee485fe3980 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -17,7 +17,6 @@ '19239', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -49,7 +48,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -81,7 +79,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -113,7 +110,6 @@ '19239', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -145,7 +141,6 @@ '58717', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -177,7 +172,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -209,7 +203,6 @@ '116682', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -241,7 +234,6 @@ '172555', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -273,7 +265,6 @@ '18894', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -305,7 +296,6 @@ '230952', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -337,7 +327,6 @@ '284942', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', @@ -369,7 +358,6 @@ '328518', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index 694fb2d51e4..9efbadff951 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -17,7 +17,6 @@ '97358', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 97f47dc7f15..b9053992ddc 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -31,7 +31,6 @@ '42581', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WAREMA Renkhoff SE', diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 3d6e3fea3b5..d66c1d2285b 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -17,7 +17,6 @@ '1234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'WOLF GmbH', diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index df0e604c550..226d0bdbba9 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -17,7 +17,6 @@ 'tmt100', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Yale Home Inc.', diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index dd2faa8b69e..3f89fe08525 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -21,7 +21,6 @@ 'online_with_doorsense', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'Yale Home Inc.', diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index ce0abffd03c..2da81a95602 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -21,7 +21,6 @@ '1234', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'test-manuf', @@ -57,7 +56,6 @@ 'efgh', ), }), - 'is_new': False, 'labels': set({ }), 'manufacturer': 'test-manuf', diff --git a/tests/syrupy.py b/tests/syrupy.py index 642e5a519b2..919ba1a6cea 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -175,6 +175,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serialized.pop("_cache") # This can be removed when suggested_area is removed from DeviceEntry serialized.pop("_suggested_area") + serialized.pop("is_new") return cls._remove_created_and_modified_at(serialized) @classmethod From bc87140a6f85d6acb2d8afb5255b3dad0115ed30 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 1 Aug 2025 11:15:49 +0200 Subject: [PATCH 0822/1118] Update after Motion Blinds tilt change (#149779) --- .../components/motion_blinds/cover.py | 14 +++++++++++++ .../components/motion_blinds/entity.py | 21 +++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 9cff2956a5f..04adc9f2d60 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -289,17 +289,23 @@ class MotionTiltDevice(MotionPositionDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 180) + await self.async_request_position_till_stop() + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 0) + await self.async_request_position_till_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, angle) + await self.async_request_position_till_stop() + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: @@ -360,11 +366,15 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open) + await self.async_request_position_till_stop() + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Close) + await self.async_request_position_till_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] @@ -376,6 +386,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.async_request_position_till_stop() + async def async_set_absolute_position(self, **kwargs): """Move the cover to a specific absolute position (see TDBU).""" angle = kwargs.get(ATTR_TILT_POSITION) @@ -390,6 +402,8 @@ class MotionTiltOnlyDevice(MotionTiltDevice): async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.async_request_position_till_stop() + class MotionTDBUDevice(MotionBaseDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 483a638a0eb..9b52cbb01f5 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -42,6 +42,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions: list[int | dict | None] = [] + self._previous_angles: list[int | None] = [] if blind.device_type in DEVICE_TYPES_WIFI: self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI @@ -112,17 +113,27 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind """Request a state update from the blind at a scheduled point in time.""" # add the last position to the list and keep the list at max 2 items self._previous_positions.append(self._blind.position) + self._previous_angles.append(self._blind.angle) if len(self._previous_positions) > 2: del self._previous_positions[: len(self._previous_positions) - 2] + if len(self._previous_angles) > 2: + del self._previous_angles[: len(self._previous_angles) - 2] async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Update_trigger) self.coordinator.async_update_listeners() - if len(self._previous_positions) < 2 or not all( - self._blind.position == prev_position - for prev_position in self._previous_positions + if ( + len(self._previous_positions) < 2 + or not all( + self._blind.position == prev_position + for prev_position in self._previous_positions + ) + or len(self._previous_angles) < 2 + or not all( + self._blind.angle == prev_angle for prev_angle in self._previous_angles + ) ): # keep updating the position @self._update_interval_moving until the position does not change. self._requesting_position = async_call_later( @@ -132,6 +143,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind ) else: self._previous_positions = [] + self._previous_angles = [] self._requesting_position = None async def async_request_position_till_stop(self, delay: int | None = None) -> None: @@ -140,7 +152,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind delay = self._update_interval_moving self._previous_positions = [] - if self._blind.position is None: + self._previous_angles = [] + if self._blind.position is None and self._blind.angle is None: return if self._requesting_position is not None: self._requesting_position() From 9f1cec893e57b73a1561078a7590b65ff9b952fb Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Fri, 1 Aug 2025 13:22:46 +0200 Subject: [PATCH 0823/1118] emoncms - fix missing data descriptions (#149733) --- homeassistant/components/emoncms/strings.json | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 900e8dd0474..f68ea92d26c 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -12,12 +12,26 @@ }, "data_description": { "url": "Server URL starting with the protocol (http or https)", - "api_key": "Your 32 bits API key" + "api_key": "Your 32 bits API key", + "sync_mode": "Pick your feeds manually (default) or synchronize them at once" } }, "choose_feeds": { "data": { "include_only_feed_id": "Choose feeds to include" + }, + "data_description": { + "include_only_feed_id": "Pick the feeds you want to synchronize" + } + }, + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::emoncms::config::step::user::data_description::url%]", + "api_key": "[%key:component::emoncms::config::step::user::data_description::api_key%]" } } }, @@ -30,8 +44,8 @@ "selector": { "sync_mode": { "options": { - "auto": "Synchronize all available Feeds", - "manual": "Select which Feeds to synchronize" + "auto": "Synchronize all available feeds", + "manual": "Select which feeds to synchronize" } } }, @@ -89,6 +103,9 @@ "init": { "data": { "include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]" + }, + "data_description": { + "include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data_description::include_only_feed_id%]" } } } From 3d4386ea6d4b5ec9f6a12ec3ee5fa3c4428b558f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 14:32:14 +0200 Subject: [PATCH 0824/1118] Add translation for `absolute_humidity` device class to `random` (#149815) --- homeassistant/components/random/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index d57f2dc8eec..1f28000d0f4 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -82,6 +82,7 @@ }, "sensor_device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", From b5e4ae4a53196b07bb2cec3a869340ba96ad70a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Aug 2025 14:36:37 +0200 Subject: [PATCH 0825/1118] Improve Tado switch tests (#149810) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tado/snapshots/test_switch.ambr | 49 +++++++++++++++++++ tests/components/tado/test_switch.py | 29 ++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 tests/components/tado/snapshots/test_switch.ambr diff --git a/tests/components/tado/snapshots/test_switch.ambr b/tests/components/tado/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c2f00649f1d --- /dev/null +++ b/tests/components/tado/snapshots/test_switch.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entities[switch.baseboard_heater_child_lock-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.baseboard_heater_child_lock', + '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': 'Child lock', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '1 1 child-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.baseboard_heater_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Child lock', + }), + 'context': , + 'entity_id': 'switch.baseboard_heater_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tado/test_switch.py b/tests/components/tado/test_switch.py index 2112f3a1ac7..6bfdf1283d1 100644 --- a/tests/components/tado/test_switch.py +++ b/tests/components/tado/test_switch.py @@ -1,28 +1,45 @@ -"""The sensor tests for the tado platform.""" +"""The switch tests for the tado platform.""" +from collections.abc import AsyncGenerator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.components.tado import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform + CHILD_LOCK_SWITCH_ENTITY = "switch.baseboard_heater_child_lock" -async def test_child_lock(hass: HomeAssistant) -> None: - """Test creation of child lock entity.""" +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.SWITCH]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of switch entities.""" await async_init_integration(hass) - state = hass.states.get(CHILD_LOCK_SWITCH_ENTITY) - assert state.state == STATE_OFF + + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize( From 5ce2729dc23e4fcdd0be80fe4128df60437747d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Aug 2025 14:36:57 +0200 Subject: [PATCH 0826/1118] Improve Tado sensor tests (#149809) --- .../tado/snapshots/test_sensor.ambr | 1240 +++++++++++++++++ tests/components/tado/test_sensor.py | 75 +- 2 files changed, 1264 insertions(+), 51 deletions(-) create mode 100644 tests/components/tado/snapshots/test_sensor.ambr diff --git a/tests/components/tado/snapshots/test_sensor.ambr b/tests/components/tado/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..2040bd737c8 --- /dev/null +++ b/tests/components/tado/snapshots/test_sensor.ambr @@ -0,0 +1,1240 @@ +# serializer version: 1 +# name: test_entities[sensor.air_conditioning_ac-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.air_conditioning_ac', + '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': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning AC', + 'time': '2020-03-05T04:01:07.162Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_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.air_conditioning_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 3 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning Humidity', + 'state_class': , + 'time': '2020-03-05T03:57:38.850Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.9', + }) +# --- +# name: test_entities[sensor.air_conditioning_tado_mode-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.air_conditioning_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_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.air_conditioning_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 3 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-05T03:57:38.850Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.76', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_ac-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.air_conditioning_with_fanlevel_ac', + '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': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with fanlevel AC', + 'time': '2022-07-13T18: 06: 58.183Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_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.air_conditioning_with_fanlevel_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 6 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning with fanlevel Humidity', + 'state_class': , + 'time': '2024-06-28T22: 23: 15.679Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.9', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_tado_mode-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.air_conditioning_with_fanlevel_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with fanlevel Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_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.air_conditioning_with_fanlevel_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 6 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_with_fanlevel_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning with fanlevel Temperature', + 'setting': 0, + 'state_class': , + 'time': '2024-06-28T22: 23: 15.679Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_fanlevel_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.3', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_ac-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.air_conditioning_with_swing_ac', + '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': 'AC', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac', + 'unique_id': 'ac 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with swing AC', + 'time': '2020-03-27T23:02:22.260Z', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ON', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_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.air_conditioning_with_swing_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 5 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Conditioning with swing Humidity', + 'state_class': , + 'time': '2020-03-28T02:09:27.830Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.3', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_tado_mode-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.air_conditioning_with_swing_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Conditioning with swing Tado mode', + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_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.air_conditioning_with_swing_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 5 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.air_conditioning_with_swing_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioning with swing Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-28T02:09:27.830Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_conditioning_with_swing_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.88', + }) +# --- +# name: test_entities[sensor.baseboard_heater_heating-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.baseboard_heater_heating', + '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': 'Heating', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating', + 'unique_id': 'heating 1 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.baseboard_heater_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Heating', + 'state_class': , + 'time': '2020-03-10T07:47:45.978Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_entities[sensor.baseboard_heater_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.baseboard_heater_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'humidity 1 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.baseboard_heater_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Baseboard Heater Humidity', + 'state_class': , + 'time': '2020-03-10T07:44:11.947Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.2', + }) +# --- +# name: test_entities[sensor.baseboard_heater_tado_mode-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.baseboard_heater_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.baseboard_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baseboard Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.baseboard_heater_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.baseboard_heater_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature 1 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.baseboard_heater_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Baseboard Heater Temperature', + 'setting': 0, + 'state_class': , + 'time': '2020-03-10T07:44:11.947Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.baseboard_heater_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.65', + }) +# --- +# name: test_entities[sensor.home_name_automatic_geofencing-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.home_name_automatic_geofencing', + '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': 'Automatic geofencing', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_geofencing', + 'unique_id': 'automatic geofencing 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_automatic_geofencing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Automatic geofencing', + }), + 'context': , + 'entity_id': 'sensor.home_name_automatic_geofencing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'True', + }) +# --- +# name: test_entities[sensor.home_name_geofencing_mode-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.home_name_geofencing_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': 'Geofencing mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'geofencing_mode', + 'unique_id': 'geofencing mode 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_geofencing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Geofencing mode', + }), + 'context': , + 'entity_id': 'sensor.home_name_geofencing_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Home (Auto)', + }) +# --- +# name: test_entities[sensor.home_name_outdoor_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.home_name_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': 'outdoor temperature 1', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.home_name_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'home name Outdoor temperature', + 'state_class': , + 'time': '2020-12-22T08:13:13.652Z', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_name_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.46', + }) +# --- +# name: test_entities[sensor.home_name_solar_percentage-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.home_name_solar_percentage', + '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': 'Solar percentage', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_percentage', + 'unique_id': 'solar percentage 1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.home_name_solar_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Solar percentage', + 'state_class': , + 'time': '2020-12-22T08:13:13.652Z', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_name_solar_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.1', + }) +# --- +# name: test_entities[sensor.home_name_tado_mode-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.home_name_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Tado mode', + }), + 'context': , + 'entity_id': 'sensor.home_name_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.home_name_weather_condition-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.home_name_weather_condition', + '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': 'Weather condition', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_condition', + 'unique_id': 'weather condition 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.home_name_weather_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home name Weather condition', + 'time': '2020-12-22T08:13:13.652Z', + }), + 'context': , + 'entity_id': 'sensor.home_name_weather_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fog', + }) +# --- +# name: test_entities[sensor.second_water_heater_tado_mode-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.second_water_heater_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.second_water_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Second Water Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.second_water_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- +# name: test_entities[sensor.water_heater_tado_mode-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.water_heater_tado_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': 'Tado mode', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tado_mode', + 'unique_id': 'tado mode 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.water_heater_tado_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Tado mode', + }), + 'context': , + 'entity_id': 'sensor.water_heater_tado_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HOME', + }) +# --- diff --git a/tests/components/tado/test_sensor.py b/tests/components/tado/test_sensor.py index 0fa7a9ca370..8445683d11d 100644 --- a/tests/components/tado/test_sensor.py +++ b/tests/components/tado/test_sensor.py @@ -1,62 +1,35 @@ """The sensor tests for the tado platform.""" +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_air_con_create_sensors(hass: HomeAssistant) -> None: - """Test creation of aircon sensors.""" + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of sensor entities.""" await async_init_integration(hass) - state = hass.states.get("sensor.air_conditioning_tado_mode") - assert state.state == "HOME" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("sensor.air_conditioning_temperature") - assert state.state == "24.76" - - state = hass.states.get("sensor.air_conditioning_ac") - assert state.state == "ON" - - state = hass.states.get("sensor.air_conditioning_humidity") - assert state.state == "60.9" - - -async def test_home_create_sensors(hass: HomeAssistant) -> None: - """Test creation of home sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.home_name_outdoor_temperature") - assert state.state == "7.46" - - state = hass.states.get("sensor.home_name_solar_percentage") - assert state.state == "2.1" - - state = hass.states.get("sensor.home_name_weather_condition") - assert state.state == "fog" - - -async def test_heater_create_sensors(hass: HomeAssistant) -> None: - """Test creation of heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.baseboard_heater_tado_mode") - assert state.state == "HOME" - - state = hass.states.get("sensor.baseboard_heater_temperature") - assert state.state == "20.65" - - state = hass.states.get("sensor.baseboard_heater_humidity") - assert state.state == "45.2" - - -async def test_water_heater_create_sensors(hass: HomeAssistant) -> None: - """Test creation of water heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("sensor.water_heater_tado_mode") - assert state.state == "HOME" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From 37579440e659aed7d6ec1a04edae2e69840fdab5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Aug 2025 14:37:12 +0200 Subject: [PATCH 0827/1118] Improve Tado climate tests (#149808) --- .../tado/snapshots/test_climate.ambr | 423 ++++++++++++++++++ tests/components/tado/test_climate.py | 126 +----- 2 files changed, 439 insertions(+), 110 deletions(-) diff --git a/tests/components/tado/snapshots/test_climate.ambr b/tests/components/tado/snapshots/test_climate.ambr index 6ba35b6f6f2..fb1dd6d46d1 100644 --- a/tests/components/tado/snapshots/test_climate.ambr +++ b/tests/components/tado/snapshots/test_climate.ambr @@ -93,6 +93,429 @@ }), ) # --- +# name: test_entities[climate.air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 60.9, + 'current_temperature': 24.8, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'friendly_name': 'Air Conditioning', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 17.8, + }), + 'context': , + 'entity_id': 'climate.air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_entities[climate.air_conditioning_with_fanlevel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'high', + 'medium', + 'auto', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'swing_modes': list([ + 'vertical', + 'horizontal', + 'both', + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning_with_fanlevel', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning_with_fanlevel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 70.9, + 'current_temperature': 24.3, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'high', + 'fan_modes': list([ + 'high', + 'medium', + 'auto', + 'low', + ]), + 'friendly_name': 'Air Conditioning with fanlevel', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'swing_mode': 'both', + 'swing_modes': list([ + 'vertical', + 'horizontal', + 'both', + 'off', + ]), + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioning_with_fanlevel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_entities[climate.air_conditioning_with_swing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioning_with_swing', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'AIR_CONDITIONING 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.air_conditioning_with_swing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 42.3, + 'current_temperature': 20.9, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'high', + 'medium', + 'low', + ]), + 'friendly_name': 'Air Conditioning with swing', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), + 'target_temp_step': 1.0, + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioning_with_swing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_entities[climate.baseboard_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.baseboard_heater', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'tado', + 'unique_id': 'HEATING 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.baseboard_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 45.2, + 'current_temperature': 20.6, + 'default_overlay_seconds': None, + 'default_overlay_type': 'MANUAL', + 'friendly_name': 'Baseboard Heater', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 31.0, + 'min_temp': 16.0, + 'offset_celsius': -1.0, + 'offset_fahrenheit': -1.8, + 'preset_mode': 'auto', + 'preset_modes': list([ + 'away', + 'home', + 'auto', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 20.5, + }), + 'context': , + 'entity_id': 'climate.baseboard_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_heater_set_temperature _Call( tuple( diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 0699551c9c0..71ee0471e5f 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -1,5 +1,6 @@ -"""The sensor tests for the tado platform.""" +"""The climate tests for the tado platform.""" +from collections.abc import AsyncGenerator from unittest.mock import patch from PyTado.interface.api.my_tado import TadoZone @@ -13,128 +14,33 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.components.tado import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration - -async def test_air_con(hass: HomeAssistant) -> None: - """Test creation of aircon climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.air_conditioning") - assert state.state == "cool" - - expected_attributes = { - "current_humidity": 60.9, - "current_temperature": 24.8, - "fan_mode": "auto", - "fan_modes": ["auto", "high", "medium", "low"], - "friendly_name": "Air Conditioning", - "hvac_action": "cooling", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "supported_features": 409, - "target_temp_step": 1, - "temperature": 17.8, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) +from tests.common import MockConfigEntry, snapshot_platform -async def test_heater(hass: HomeAssistant) -> None: - """Test creation of heater climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.baseboard_heater") - assert state.state == "heat" - - expected_attributes = { - "current_humidity": 45.2, - "current_temperature": 20.6, - "friendly_name": "Baseboard Heater", - "hvac_action": "idle", - "hvac_modes": ["off", "auto", "heat"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "supported_features": 401, - "target_temp_step": 1, - "temperature": 20.5, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.CLIMATE]): + yield -async def test_smartac_with_swing(hass: HomeAssistant) -> None: - """Test creation of smart ac with swing climate.""" - - await async_init_integration(hass) - - state = hass.states.get("climate.air_conditioning_with_swing") - assert state.state == "auto" - - expected_attributes = { - "current_humidity": 42.3, - "current_temperature": 20.9, - "fan_mode": "auto", - "fan_modes": ["auto", "high", "medium", "low"], - "friendly_name": "Air Conditioning with swing", - "hvac_action": "heating", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 30.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "swing_modes": ["on", "off"], - "supported_features": 441, - "target_temp_step": 1.0, - "temperature": 20.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) - - -async def test_smartac_with_fanlevel_vertical_and_horizontal_swing( - hass: HomeAssistant, +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: - """Test creation of smart ac with swing climate.""" + """Test creation of climate entities.""" await async_init_integration(hass) - state = hass.states.get("climate.air_conditioning_with_fanlevel") - assert state.state == "heat" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - expected_attributes = { - "current_humidity": 70.9, - "current_temperature": 24.3, - "fan_mode": "high", - "fan_modes": ["high", "medium", "auto", "low"], - "friendly_name": "Air Conditioning with fanlevel", - "hvac_action": "heating", - "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], - "max_temp": 31.0, - "min_temp": 16.0, - "preset_mode": "auto", - "preset_modes": ["away", "home", "auto"], - "swing_modes": ["vertical", "horizontal", "both", "off"], - "supported_features": 441, - "target_temp_step": 1.0, - "temperature": 25.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_heater_set_temperature( From 506431c75fb9ceb0bb142fc6e0b07949828b178c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Aug 2025 14:38:02 +0200 Subject: [PATCH 0828/1118] Improve Tado water heater tests (#149806) --- .../tado/snapshots/test_water_heater.ambr | 139 ++++++++++++++++++ tests/components/tado/test_water_heater.py | 62 +++----- 2 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 tests/components/tado/snapshots/test_water_heater.ambr diff --git a/tests/components/tado/snapshots/test_water_heater.ambr b/tests/components/tado/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..5e10af60c8d --- /dev/null +++ b/tests/components/tado/snapshots/test_water_heater.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_entities[water_heater.second_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.second_water_heater', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[water_heater.second_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Second Water Heater', + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + 'operation_mode': 'heat', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 30.0, + }), + 'context': , + 'entity_id': 'water_heater.second_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_entities[water_heater.water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.water_heater', + '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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[water_heater.water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Water Heater', + 'max_temp': 31.0, + 'min_temp': 16.0, + 'operation_list': list([ + 'auto', + 'heat', + 'off', + ]), + 'operation_mode': 'auto', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 65.0, + }), + 'context': , + 'entity_id': 'water_heater.water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/tado/test_water_heater.py b/tests/components/tado/test_water_heater.py index 223a1fda16a..7c13ba1604e 100644 --- a/tests/components/tado/test_water_heater.py +++ b/tests/components/tado/test_water_heater.py @@ -1,49 +1,35 @@ -"""The sensor tests for the tado platform.""" +"""The water heater tests for the tado platform.""" +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_water_heater_create_sensors(hass: HomeAssistant) -> None: + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.WATER_HEATER]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test creation of water heater.""" await async_init_integration(hass) - state = hass.states.get("water_heater.water_heater") - assert state.state == "auto" + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - expected_attributes = { - "current_temperature": None, - "friendly_name": "Water Heater", - "max_temp": 31.0, - "min_temp": 16.0, - "operation_list": ["auto", "heat", "off"], - "operation_mode": "auto", - "supported_features": 3, - "target_temp_high": None, - "target_temp_low": None, - "temperature": 65.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) - - state = hass.states.get("water_heater.second_water_heater") - assert state.state == "heat" - - expected_attributes = { - "current_temperature": None, - "friendly_name": "Second Water Heater", - "max_temp": 31.0, - "min_temp": 16.0, - "operation_list": ["auto", "heat", "off"], - "operation_mode": "heat", - "supported_features": 3, - "target_temp_high": None, - "target_temp_low": None, - "temperature": 30.0, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From a08c3c9f442e055e740022ad8f604725b4cc5c98 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Aug 2025 14:38:12 +0200 Subject: [PATCH 0829/1118] Improve Tado binary sensor tests (#149807) --- .../tado/snapshots/test_binary_sensor.ambr | 1230 +++++++++++++++++ tests/components/tado/test_binary_sensor.py | 84 +- 2 files changed, 1255 insertions(+), 59 deletions(-) create mode 100644 tests/components/tado/snapshots/test_binary_sensor.ambr diff --git a/tests/components/tado/snapshots/test_binary_sensor.ambr b/tests/components/tado/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5920e6bbf11 --- /dev/null +++ b/tests/components/tado/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1230 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.air_conditioning_connectivity-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.air_conditioning_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_overlay-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.air_conditioning_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning Overlay', + 'termination': 'TADO_MODE', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_power-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.air_conditioning_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_window-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.air_conditioning_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 3 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_connectivity-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.air_conditioning_with_fanlevel_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning with fanlevel Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_overlay-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.air_conditioning_with_fanlevel_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with fanlevel Overlay', + 'termination': 'MANUAL', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_power-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.air_conditioning_with_fanlevel_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with fanlevel Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_window-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.air_conditioning_with_fanlevel_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 6 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_fanlevel_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning with fanlevel Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_fanlevel_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_connectivity-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.air_conditioning_with_swing_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Air Conditioning with swing Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_overlay-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.air_conditioning_with_swing_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with swing Overlay', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_power-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.air_conditioning_with_swing_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Air Conditioning with swing Power', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_window-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.air_conditioning_with_swing_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 5 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.air_conditioning_with_swing_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Air Conditioning with swing Window', + }), + 'context': , + 'entity_id': 'binary_sensor.air_conditioning_with_swing_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_connectivity-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.baseboard_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Baseboard Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_early_start-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.baseboard_heater_early_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Early start', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'early_start', + 'unique_id': 'early start 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_early_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Early start', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_early_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_overlay-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.baseboard_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Overlay', + 'termination': 'MANUAL', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_power-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.baseboard_heater_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Baseboard Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_window-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.baseboard_heater_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Window', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'open window 1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.baseboard_heater_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Baseboard Heater Window', + }), + 'context': , + 'entity_id': 'binary_sensor.baseboard_heater_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_connectivity-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.second_water_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Second Water Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_overlay-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.second_water_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Second Water Heater Overlay', + 'termination': 'TADO_MODE', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_power-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.second_water_heater_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.second_water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Second Water Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.second_water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.water_heater_connectivity-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.water_heater_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'link 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Water Heater Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.water_heater_overlay-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.water_heater_overlay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overlay', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overlay', + 'unique_id': 'overlay 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_overlay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Overlay', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_overlay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.water_heater_power-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.water_heater_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': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'power 2 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.water_heater_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Water Heater Power', + }), + 'context': , + 'entity_id': 'binary_sensor.water_heater_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.wr1_connection_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.wr1_connection_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': 'Connection state', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_state', + 'unique_id': 'connection state WR1 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.wr1_connection_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'WR1 Connection state', + }), + 'context': , + 'entity_id': 'binary_sensor.wr1_connection_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.wr4_connection_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.wr4_connection_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': 'Connection state', + 'platform': 'tado', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'connection_state', + 'unique_id': 'connection state WR4 1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.wr4_connection_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'WR4 Connection state', + }), + 'context': , + 'entity_id': 'binary_sensor.wr4_connection_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tado/test_binary_sensor.py b/tests/components/tado/test_binary_sensor.py index 78cd91c56c6..9a0b94883fa 100644 --- a/tests/components/tado/test_binary_sensor.py +++ b/tests/components/tado/test_binary_sensor.py @@ -1,69 +1,35 @@ -"""The sensor tests for the tado platform.""" +"""The binary sensor tests for the tado platform.""" -from homeassistant.const import STATE_OFF, STATE_ON +from collections.abc import AsyncGenerator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .util import async_init_integration +from tests.common import MockConfigEntry, snapshot_platform -async def test_air_con_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of aircon sensors.""" + +@pytest.fixture(autouse=True) +def setup_platforms() -> AsyncGenerator[None]: + """Set up the platforms for the tests.""" + with patch("homeassistant.components.tado.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + +async def test_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test creation of binary sensor.""" await async_init_integration(hass) - state = hass.states.get("binary_sensor.air_conditioning_power") - assert state.state == STATE_ON + config_entry: MockConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("binary_sensor.air_conditioning_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.air_conditioning_overlay") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.air_conditioning_window") - assert state.state == STATE_OFF - - -async def test_heater_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.baseboard_heater_power") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_early_start") - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.baseboard_heater_overlay") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.baseboard_heater_window") - assert state.state == STATE_OFF - - -async def test_water_heater_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of water heater sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.water_heater_connectivity") - assert state.state == STATE_ON - - state = hass.states.get("binary_sensor.water_heater_overlay") - assert state.state == STATE_OFF - - state = hass.states.get("binary_sensor.water_heater_power") - assert state.state == STATE_ON - - -async def test_home_create_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of home binary sensors.""" - - await async_init_integration(hass) - - state = hass.states.get("binary_sensor.wr1_connection_state") - assert state.state == STATE_ON + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From f538807d6eb522d2389ad040493780ed88f22516 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Aug 2025 14:54:58 +0200 Subject: [PATCH 0830/1118] Make device suggested_area only influence new devices (#149758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/device_registry.py | 43 +++++++++++------- tests/helpers/test_device_registry.py | 57 +++++++++++++++++------- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index d3866d8c9c3..72d0cf651f2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -906,7 +906,19 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: deleted_device = self.deleted_devices.get_entry(identifiers, connections) if deleted_device is None: - device = DeviceEntry(is_new=True) + area_id: str | None = None + if ( + suggested_area is not None + and suggested_area is not UNDEFINED + and suggested_area != "" + ): + # Circular dep + from . import area_registry as ar # noqa: PLC0415 + + area = ar.async_get(self.hass).async_get_or_create(suggested_area) + area_id = area.id + device = DeviceEntry(is_new=True, area_id=area_id) + else: self.deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( @@ -961,7 +973,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id=model_id, name=name, serial_number=serial_number, - suggested_area=suggested_area, + _suggested_area=suggested_area, sw_version=sw_version, via_device_id=via_device_id, ) @@ -1000,6 +1012,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): remove_config_entry_id: str | UndefinedType = UNDEFINED, remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, + # _suggested_area is used internally by the device registry and must + # not be set by integrations. + _suggested_area: str | None | UndefinedType = UNDEFINED, + # suggested_area is deprecated and will be removed in 2026.9 suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, @@ -1065,19 +1081,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Cannot define both merge_identifiers and new_identifiers" ) - if ( - suggested_area is not None - and suggested_area is not UNDEFINED - and suggested_area != "" - and area_id is UNDEFINED - and old.area_id is None - ): - # Circular dep - from . import area_registry as ar # noqa: PLC0415 - - area = ar.async_get(self.hass).async_get_or_create(suggested_area) - area_id = area.id - if add_config_entry_id is not UNDEFINED: if add_config_subentry_id is UNDEFINED: # Interpret not specifying a subentry as None (the main entry) @@ -1155,6 +1158,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values["config_entries_subentries"] = config_entries_subentries old_values["config_entries_subentries"] = old.config_entries_subentries + if suggested_area is not UNDEFINED: + report_usage( + "passes a suggested_area to device_registry.async_update device", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.9.0", + ) + + if _suggested_area is not UNDEFINED: + suggested_area = _suggested_area + added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 4247da296fd..d056c25fc3b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -3210,20 +3210,35 @@ async def test_update_remove_config_subentries( } +@pytest.mark.parametrize( + ("initial_area", "device_area_id", "number_of_areas"), + [ + (None, None, 0), + ("Living Room", "living_room", 1), + ], +) async def test_update_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, mock_config_entry: MockConfigEntry, + initial_area: str | None, + device_area_id: str | None, + number_of_areas: int, ) -> None: - """Verify that we can update the suggested area version of a device.""" + """Verify that we can update the suggested area of a device. + + Updating the suggested area of a device should not create a new area, nor should + it change the area_id of the device. + """ update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bla", "123")}, + suggested_area=initial_area, ) - assert entry.area_id is None + assert entry.area_id == device_area_id suggested_area = "Pool" @@ -3232,26 +3247,24 @@ async def test_update_suggested_area( entry.id, suggested_area=suggested_area ) - assert mock_save.call_count == 1 + # Check the device registry was not saved + assert mock_save.call_count == 0 assert updated_entry != entry + assert updated_entry.area_id == device_area_id - pool_area = area_registry.async_get_area_by_name("Pool") - assert pool_area is not None - assert updated_entry.area_id == pool_area.id - assert len(area_registry.areas) == 1 + # Check we did not create an area + pool_area = area_registry.async_get_area_by_name(suggested_area) + assert pool_area is None + assert updated_entry.area_id == device_area_id + assert len(area_registry.areas) == number_of_areas await hass.async_block_till_done() - assert len(update_events) == 2 + assert len(update_events) == 1 assert update_events[0].data == { "action": "create", "device_id": entry.id, } - assert update_events[1].data == { - "action": "update", - "device_id": entry.id, - "changes": {"area_id": None, "suggested_area": None}, - } # Do not save or fire the event if the suggested # area does not result in a change of area @@ -3260,10 +3273,10 @@ async def test_update_suggested_area( updated_entry = device_registry.async_update_device( entry.id, suggested_area="Other" ) - assert len(update_events) == 2 + assert len(update_events) == 1 assert mock_save_2.call_count == 0 assert updated_entry != entry - assert updated_entry.area_id == pool_area.id + assert updated_entry.area_id == device_area_id async def test_cleanup_device_registry( @@ -3397,11 +3410,13 @@ async def test_cleanup_entity_registry_change( assert len(mock_call.mock_calls) == 2 +@pytest.mark.parametrize("initial_area", [None, "12345A"]) @pytest.mark.usefixtures("freezer") async def test_restore_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_config_entry_with_subentries: MockConfigEntry, + initial_area: str | None, ) -> None: """Make sure device id is stable.""" entry_id = mock_config_entry_with_subentries.entry_id @@ -3428,7 +3443,7 @@ async def test_restore_device( # Apply user customizations entry = device_registry.async_update_device( entry.id, - area_id="12345A", + area_id=initial_area, disabled_by=dr.DeviceEntryDisabler.USER, labels={"label1", "label2"}, name_by_user="Test Friendly Name", @@ -3493,7 +3508,7 @@ async def test_restore_device( via_device="via_device_id_new", ) assert entry3 == dr.DeviceEntry( - area_id="12345A", + area_id=initial_area, config_entries={entry_id}, config_entries_subentries={entry_id: {subentry_id}}, configuration_url="http://config_url_new.bla", @@ -4933,3 +4948,11 @@ async def test_suggested_area_deprecation( "The deprecated function suggested_area was called. It will be removed in " "HA Core 2026.9. Use code which ignores suggested_area instead" ) in caplog.text + + device_registry.async_update_device(entry.id, suggested_area="TV Room") + + assert ( + "Detected code that passes a suggested_area to device_registry.async_update " + "device. This will stop working in Home Assistant 2026.9.0, please report " + "this issue" + ) in caplog.text From fb2d62d69242861aac77cec1043ac1da09584c07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 15:57:47 +0200 Subject: [PATCH 0831/1118] Add translation for `absolute_humidity` device class to `mqtt` (#149818) --- homeassistant/components/mqtt/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 40215b0f2c6..0e248cfd2d2 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1104,6 +1104,7 @@ }, "device_class_sensor": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", From b4a4e218ec98479954e94dfc9d6f058faa86c015 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 1 Aug 2025 16:42:59 +0200 Subject: [PATCH 0832/1118] Add re-authentication to BSBLan (#146280) Co-authored-by: Norbert Rittel --- .../components/bsblan/config_flow.py | 187 ++++++++- .../components/bsblan/coordinator.py | 14 +- homeassistant/components/bsblan/strings.json | 15 +- tests/components/bsblan/test_config_flow.py | 370 +++++++++++++++++- tests/components/bsblan/test_init.py | 34 +- 5 files changed, 577 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 6abfe57a4ae..1491322ae13 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from bsblan import BSBLAN, BSBLANConfig, BSBLANError +from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -45,7 +46,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) - return await self._validate_and_create() + return await self._validate_and_create(user_input) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -128,14 +129,29 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) - return await self._validate_and_create(is_discovery=True) + return await self._validate_and_create(user_input, is_discovery=True) async def _validate_and_create( - self, is_discovery: bool = False + self, user_input: dict[str, Any], is_discovery: bool = False ) -> ConfigFlowResult: """Validate device connection and create entry.""" try: - await self._get_bsblan_info(is_discovery=is_discovery) + await self._get_bsblan_info() + except BSBLANAuthError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "invalid_auth"}, + description_placeholders={"host": str(self.host)}, + ) + return self._show_setup_form({"base": "invalid_auth"}, user_input) except BSBLANError: if is_discovery: return self.async_show_form( @@ -154,18 +170,145 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation flow.""" + existing_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert existing_entry + + if user_input is None: + # Preserve existing values as defaults + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Optional( + CONF_PASSKEY, + default=existing_entry.data.get( + CONF_PASSKEY, vol.UNDEFINED + ), + ): str, + vol.Optional( + CONF_USERNAME, + default=existing_entry.data.get( + CONF_USERNAME, vol.UNDEFINED + ), + ): str, + vol.Optional( + CONF_PASSWORD, + default=vol.UNDEFINED, + ): str, + } + ), + ) + + # Use existing host and port, update auth credentials + self.host = existing_entry.data[CONF_HOST] + self.port = existing_entry.data[CONF_PORT] + self.passkey = user_input.get(CONF_PASSKEY) or existing_entry.data.get( + CONF_PASSKEY + ) + self.username = user_input.get(CONF_USERNAME) or existing_entry.data.get( + CONF_USERNAME + ) + self.password = user_input.get(CONF_PASSWORD) + + try: + await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) + except BSBLANAuthError: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Optional( + CONF_PASSKEY, + default=user_input.get(CONF_PASSKEY, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_USERNAME, + default=user_input.get(CONF_USERNAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_PASSWORD, + default=vol.UNDEFINED, + ): str, + } + ), + errors={"base": "invalid_auth"}, + ) + except BSBLANError: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Optional( + CONF_PASSKEY, + default=user_input.get(CONF_PASSKEY, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_USERNAME, + default=user_input.get(CONF_USERNAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_PASSWORD, + default=vol.UNDEFINED, + ): str, + } + ), + errors={"base": "cannot_connect"}, + ) + + # Update the config entry with new auth data + data_updates = {} + if self.passkey is not None: + data_updates[CONF_PASSKEY] = self.passkey + if self.username is not None: + data_updates[CONF_USERNAME] = self.username + if self.password is not None: + data_updates[CONF_PASSWORD] = self.password + + return self.async_update_reload_and_abort( + existing_entry, data_updates=data_updates, reason="reauth_successful" + ) + @callback - def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: + def _show_setup_form( + self, errors: dict | None = None, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Show the setup form to the user.""" + # Preserve user input if provided, otherwise use defaults + defaults = user_input or {} + return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_PASSKEY): str, - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, + vol.Required( + CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT) + ): int, + vol.Optional( + CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_USERNAME, + default=defaults.get(CONF_USERNAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_PASSWORD, + default=defaults.get(CONF_PASSWORD, vol.UNDEFINED), + ): str, } ), errors=errors or {}, @@ -186,7 +329,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): ) async def _get_bsblan_info( - self, raise_on_progress: bool = True, is_discovery: bool = False + self, + raise_on_progress: bool = True, + is_reauth: bool = False, ) -> None: """Get device information from a BSBLAN device.""" config = BSBLANConfig( @@ -209,11 +354,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): format_mac(self.mac), raise_on_progress=raise_on_progress ) - # Always allow updating host/port for both user and discovery flows - # This ensures connectivity is maintained when devices change IP addresses - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.host, - CONF_PORT: self.port, - } - ) + # Skip unique_id configuration check during reauth to prevent "already_configured" abort + if not is_reauth: + # Always allow updating host/port for both user and discovery flows + # This ensures connectivity is maintained when devices change IP addresses + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 5c5e23efa8a..38a19dba8ea 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -4,11 +4,19 @@ from dataclasses import dataclass from datetime import timedelta from random import randint -from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State +from bsblan import ( + BSBLAN, + BSBLANAuthError, + BSBLANConnectionError, + HotWaterState, + Sensor, + State, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL @@ -62,6 +70,10 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): state = await self.client.state() sensor = await self.client.sensor() dhw = await self.client.hot_water_state() + except BSBLANAuthError as err: + raise ConfigEntryAuthFailed( + "Authentication failed for BSB-Lan device" + ) from err except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" raise UpdateFailed( diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index cd4633dfb86..86e52e76f41 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -33,14 +33,25 @@ "username": "[%key:component::bsblan::config::step::user::data_description::username%]", "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The BSB-Lan integration needs to re-authenticate with {name}", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "exceptions": { diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 72360ece687..3ca0de5b78f 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -3,11 +3,11 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError import pytest from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -129,7 +129,7 @@ async def test_full_user_flow_implementation( result = await _init_user_flow(hass) _assert_form_result(result, "user") - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -142,7 +142,7 @@ async def test_full_user_flow_implementation( ) _assert_create_entry_result( - result2, + result, format_mac("00:80:41:19:69:90"), { CONF_HOST: "127.0.0.1", @@ -185,6 +185,94 @@ async def test_connection_error( _assert_form_result(result, "user", {"base": "cannot_connect"}) +async def test_authentication_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test we show user form on BSBLan authentication error with field preservation.""" + mock_bsblan.device.side_effect = BSBLANAuthError + + user_input = { + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_PASSKEY: "secret", + CONF_USERNAME: "testuser", + CONF_PASSWORD: "wrongpassword", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "invalid_auth"} + assert result.get("step_id") == "user" + + # Verify that user input is preserved in the form + data_schema = result.get("data_schema") + assert data_schema is not None + + # Check that the form fields contain the previously entered values + host_field = next( + field for field in data_schema.schema if field.schema == CONF_HOST + ) + port_field = next( + field for field in data_schema.schema if field.schema == CONF_PORT + ) + passkey_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSKEY + ) + username_field = next( + field for field in data_schema.schema if field.schema == CONF_USERNAME + ) + password_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSWORD + ) + + # The defaults are callable functions, so we need to call them + assert host_field.default() == "192.168.1.100" + assert port_field.default() == 8080 + assert passkey_field.default() == "secret" + assert username_field.default() == "testuser" + assert password_field.default() == "wrongpassword" + + +async def test_authentication_error_vs_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that authentication and connection errors are handled differently.""" + # Test connection error first + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Reset and test authentication error + mock_bsblan.device.side_effect = BSBLANAuthError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "wrongpass", + }, + ) + + _assert_form_result(result, "user", {"base": "invalid_auth"}) + + async def test_user_device_exists_abort( hass: HomeAssistant, mock_bsblan: MagicMock, @@ -217,7 +305,7 @@ async def test_zeroconf_discovery( result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) _assert_form_result(result, "discovery_confirm") - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -228,7 +316,7 @@ async def test_zeroconf_discovery( ) _assert_create_entry_result( - result2, + result, format_mac("00:80:41:19:69:90"), { CONF_HOST: "10.0.2.60", @@ -285,7 +373,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth( # Reset side_effect for the second call to succeed mock_bsblan.device.side_effect = None - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -295,7 +383,7 @@ async def test_zeroconf_discovery_no_mac_requires_auth( ) _assert_create_entry_result( - result2, + result, "00:80:41:19:69:90", # MAC from fixture file { CONF_HOST: "10.0.2.60", @@ -324,10 +412,10 @@ async def test_zeroconf_discovery_no_mac_no_auth_required( _assert_form_result(result, "discovery_confirm") # User confirms the discovery - result2 = await _configure_flow(hass, result["flow_id"], {}) + result = await _configure_flow(hass, result["flow_id"], {}) _assert_create_entry_result( - result2, + result, "00:80:41:19:69:90", # MAC from fixture file { CONF_HOST: "10.0.2.60", @@ -355,7 +443,7 @@ async def test_zeroconf_discovery_connection_error( result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) _assert_form_result(result, "discovery_confirm") - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -365,7 +453,7 @@ async def test_zeroconf_discovery_connection_error( }, ) - _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"}) async def test_zeroconf_discovery_updates_host_port_on_existing_entry( @@ -445,7 +533,7 @@ async def test_zeroconf_discovery_connection_error_recovery( result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) _assert_form_result(result, "discovery_confirm") - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -455,12 +543,12 @@ async def test_zeroconf_discovery_connection_error_recovery( }, ) - _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + _assert_form_result(result, "discovery_confirm", {"base": "cannot_connect"}) # Second attempt succeeds (connection is fixed) mock_bsblan.device.side_effect = None - result3 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -471,7 +559,7 @@ async def test_zeroconf_discovery_connection_error_recovery( ) _assert_create_entry_result( - result3, + result, format_mac("00:80:41:19:69:90"), { CONF_HOST: "10.0.2.60", @@ -513,7 +601,7 @@ async def test_connection_error_recovery( # Second attempt succeeds (connection is fixed) mock_bsblan.device.side_effect = None - result2 = await _configure_flow( + result = await _configure_flow( hass, result["flow_id"], { @@ -526,7 +614,7 @@ async def test_connection_error_recovery( ) _assert_create_entry_result( - result2, + result, format_mac("00:80:41:19:69:90"), { CONF_HOST: "127.0.0.1", @@ -567,3 +655,249 @@ async def test_zeroconf_discovery_no_mac_duplicate_host_port( # Should not call device API since we abort early assert len(mock_bsblan.device.mock_calls) == 0 + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful reauth flow.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Check that the form has the correct description placeholder + assert result.get("description_placeholders") == {"name": "BSBLAN Setup"} + + # Check that existing values are preserved as defaults + data_schema = result.get("data_schema") + assert data_schema is not None + + # Complete reauth with new credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "new_passkey", + CONF_USERNAME: "new_admin", + CONF_PASSWORD: "new_password", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify config entry was updated with new credentials + assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey" + assert mock_config_entry.data[CONF_USERNAME] == "new_admin" + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + # Verify host and port remain unchanged + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_PORT] == 80 + + +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with authentication error.""" + mock_config_entry.add_to_hass(hass) + + # Mock authentication error + mock_bsblan.device.side_effect = BSBLANAuthError + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit with wrong credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "wrong_passkey", + CONF_USERNAME: "wrong_admin", + CONF_PASSWORD: "wrong_password", + }, + ) + + _assert_form_result(result, "reauth_confirm", {"base": "invalid_auth"}) + + # Verify that user input is preserved in the form after error + data_schema = result.get("data_schema") + assert data_schema is not None + + # Check that the form fields contain the previously entered values + passkey_field = next( + field for field in data_schema.schema if field.schema == CONF_PASSKEY + ) + username_field = next( + field for field in data_schema.schema if field.schema == CONF_USERNAME + ) + + assert passkey_field.default() == "wrong_passkey" + assert username_field.default() == "wrong_admin" + + +async def test_reauth_flow_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with connection error.""" + mock_config_entry.add_to_hass(hass) + + # Mock connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit credentials but get connection error + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "reauth_confirm", {"base": "cannot_connect"}) + + +async def test_reauth_flow_preserves_existing_values( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that reauth flow preserves existing values when user doesn't change them.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + _assert_form_result(result, "reauth_confirm") + + # Submit without changing any credentials (only password is provided) + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSWORD: "new_password_only", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that existing passkey and username are preserved + assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original value + assert mock_config_entry.data[CONF_USERNAME] == "admin" # Original value + assert mock_config_entry.data[CONF_PASSWORD] == "new_password_only" # New value + + +async def test_reauth_flow_partial_credentials_update( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with partial credential updates.""" + mock_config_entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + # Submit with only username and password changes + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "new_admin", + CONF_PASSWORD: "new_password", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify partial update: passkey preserved, username and password updated + assert mock_config_entry.data[CONF_PASSKEY] == "1234" # Original preserved + assert mock_config_entry.data[CONF_USERNAME] == "new_admin" # Updated + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" # Updated + # Host and port should remain unchanged + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_PORT] == 80 + + +async def test_zeroconf_discovery_auth_error_during_confirm( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test authentication error during discovery_confirm step.""" + # Remove MAC from discovery to force discovery_confirm step + zeroconf_discovery_info.properties.pop("mac", None) + + # Setup device to require authentication during initial discovery + mock_bsblan.device.side_effect = BSBLANError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_info, + ) + + _assert_form_result(result, "discovery_confirm") + + # Now setup auth error for the confirmation step + mock_bsblan.device.side_effect = BSBLANAuthError + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "wrong_key", + CONF_USERNAME: "admin", + CONF_PASSWORD: "wrong_password", + }, + ) + + # Should show the discovery_confirm form again with auth error + _assert_form_result(result, "discovery_confirm", {"base": "invalid_auth"}) diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index a9c3605f67f..cc52799d28b 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -2,13 +2,14 @@ from unittest.mock import MagicMock -from bsblan import BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload_config_entry( @@ -45,3 +46,32 @@ async def test_config_entry_not_ready( assert len(mock_bsblan.state.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_failed_triggers_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that BSBLANAuthError during coordinator update triggers reauth flow.""" + # First, set up the integration successfully + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Mock BSBLANAuthError during next update + mock_bsblan.initialize.side_effect = BSBLANAuthError("Authentication failed") + + # Advance time by the coordinator's update interval to trigger update + freezer.tick(delta=20) # Advance beyond the 12 second scan interval + random offset + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that a reauth flow has been started + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id From 8d68fee9f8cebb7586a79d8367425c5ed1f77d1a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 19:30:59 +0200 Subject: [PATCH 0833/1118] Add translation for `absolute_humidity` device class to `template` (#149814) --- homeassistant/components/template/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index d29bfbeb3fb..fc889ff9f8f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -901,6 +901,7 @@ }, "sensor_device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", From d43f21c2e238bb18d39ebcb29e2e810e8c04e4bc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 20:35:48 +0200 Subject: [PATCH 0834/1118] Fix descriptions for template number fields (#149804) --- homeassistant/components/template/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index fc889ff9f8f..cdaeacbe842 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -278,10 +278,10 @@ "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", "state": "Template for the number's current value.", - "step": "Template for the number's increment/decrement step.", + "step": "Defines the number's increment/decrement step.", "set_value": "Defines actions to run when the number is set to a value. Receives variable `value`.", - "max": "Template for the number's maximum value.", - "min": "Template for the number's minimum value.", + "max": "Defines the number's maximum value.", + "min": "Defines the number's minimum value.", "unit_of_measurement": "Defines the unit of measurement of the number, if any." }, "sections": { From 9394546668b444d28302671281b29cea664bdd30 Mon Sep 17 00:00:00 2001 From: kizovinh Date: Sat, 2 Aug 2025 02:00:53 +0700 Subject: [PATCH 0835/1118] Add EZVIZ battery camera power status and online status sensor (#146822) --- homeassistant/components/ezviz/sensor.py | 46 +++++++++++++++++---- homeassistant/components/ezviz/strings.json | 12 ++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index c441b34b42d..ec631e8e5c1 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { key="last_alarm_type_name", translation_key="last_alarm_type_name", ), + "Record_Mode": SensorEntityDescription( + key="Record_Mode", + translation_key="record_mode", + entity_registry_enabled_default=False, + ), + "battery_camera_work_mode": SensorEntityDescription( + key="battery_camera_work_mode", + translation_key="battery_camera_work_mode", + entity_registry_enabled_default=False, + ), + "powerStatus": SensorEntityDescription( + key="powerStatus", + translation_key="power_status", + entity_registry_enabled_default=False, + ), + "OnlineStatus": SensorEntityDescription( + key="OnlineStatus", + translation_key="online_status", + entity_registry_enabled_default=False, + ), } @@ -76,16 +96,26 @@ async def async_setup_entry( ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data + entities: list[EzvizSensor] = [] - async_add_entities( - [ + for camera, sensors in coordinator.data.items(): + entities.extend( EzvizSensor(coordinator, camera, sensor) - for camera in coordinator.data - for sensor, value in coordinator.data[camera].items() - if sensor in SENSOR_TYPES - if value is not None - ] - ) + for sensor, value in sensors.items() + if sensor in SENSOR_TYPES and value is not None + ) + + optionals = sensors.get("optionals", {}) + entities.extend( + EzvizSensor(coordinator, camera, optional_key) + for optional_key in ("powerStatus", "OnlineStatus") + if optional_key in optionals + ) + + if "mode" in optionals.get("Record_Mode", {}): + entities.append(EzvizSensor(coordinator, camera, "mode")) + + async_add_entities(entities) class EzvizSensor(EzvizEntity, SensorEntity): diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index b03a5dbc61a..ad8f7114407 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -147,6 +147,18 @@ }, "last_alarm_type_name": { "name": "Last alarm type name" + }, + "record_mode": { + "name": "Record mode" + }, + "battery_camera_work_mode": { + "name": "Battery work mode" + }, + "power_status": { + "name": "Power status" + }, + "online_status": { + "name": "Online status" } }, "switch": { From 9616c8cd7bec74f9096e93727a6a6fa6a2ec0757 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Fri, 1 Aug 2025 21:04:16 +0200 Subject: [PATCH 0836/1118] Bump pyemoncms to 0.1.2 (#149825) --- homeassistant/components/emoncms/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index c7f18cb205e..bc86e6e9bab 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", "iot_class": "local_polling", - "requirements": ["pyemoncms==0.1.1"] + "requirements": ["pyemoncms==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index baad7de1409..6af1d9cb285 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyefergy==22.5.0 pyegps==0.2.5 # homeassistant.components.emoncms -pyemoncms==0.1.1 +pyemoncms==0.1.2 # homeassistant.components.enphase_envoy pyenphase==2.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93709fec4b5..d7efdf66a44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ pyefergy==22.5.0 pyegps==0.2.5 # homeassistant.components.emoncms -pyemoncms==0.1.1 +pyemoncms==0.1.2 # homeassistant.components.enphase_envoy pyenphase==2.2.3 From ae42d71123a9f5732a1c664c29359b709df1285d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 21:33:47 +0200 Subject: [PATCH 0837/1118] Add translations for recently introduced device classes to `sql` (#149821) --- homeassistant/components/sql/strings.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index f9b8044e992..cbc0deda96a 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -71,10 +71,13 @@ "selector": { "device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", @@ -85,6 +88,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -115,13 +119,14 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, From 8562c8d32f7e8fff5cc332776beb77ef7f3b91c6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 21:34:31 +0200 Subject: [PATCH 0838/1118] Add translations for recently introduced device classes to `scrape` (#149822) --- homeassistant/components/scrape/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index d46f63c9516..91452287ce7 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -139,6 +139,7 @@ "selector": { "device_class": { "options": { + "absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "area": "[%key:component::sensor::entity_component::area::name%]", @@ -155,6 +156,7 @@ "distance": "[%key:component::sensor::entity_component::distance::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", "frequency": "[%key:component::sensor::entity_component::frequency::name%]", "gas": "[%key:component::sensor::entity_component::gas::name%]", @@ -184,13 +186,14 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", "water": "[%key:component::sensor::entity_component::water::name%]", "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, From d45c03a79515aef1e1d847bea02e1a33fabcff65 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 21:35:04 +0200 Subject: [PATCH 0839/1118] Update reference for `volatile_organic_compounds_parts` in `random` (#149832) --- homeassistant/components/random/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index 1f28000d0f4..450f78f9e83 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -130,7 +130,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", From b0e75e9ee47d515623007c3f68754337b37bc25c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 1 Aug 2025 21:36:10 +0200 Subject: [PATCH 0840/1118] Update reference for `volatile_organic_compounds_parts` in `template` (#149831) --- homeassistant/components/template/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index cdaeacbe842..be5fb1866ea 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -949,7 +949,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", From bddd4d621a98f5ba1f0c0e46b01d148e645b504c Mon Sep 17 00:00:00 2001 From: Jamin Date: Fri, 1 Aug 2025 14:37:45 -0500 Subject: [PATCH 0841/1118] Bump VoIP utils to 0.3.4 (#149786) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 0b533795a2c..fe855159d55 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.3"] + "requirements": ["voip-utils==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6af1d9cb285..e381f0f4363 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3057,7 +3057,7 @@ venstarcolortouch==0.21 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.3 +voip-utils==0.3.4 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7efdf66a44..ee4a582885d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2525,7 +2525,7 @@ venstarcolortouch==0.21 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.3 +voip-utils==0.3.4 # homeassistant.components.volvo volvocarsapi==0.4.1 From 45f6778ff480add585ac2585d4a35f4c60e5f762 Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Sat, 2 Aug 2025 18:37:57 +0200 Subject: [PATCH 0842/1118] Fix Miele hob translation keys (#149865) --- homeassistant/components/miele/strings.json | 42 ++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index a4400ff26eb..90689a3d9cc 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -203,27 +203,27 @@ "plate": { "name": "Plate {plate_no}", "state": { - "power_step_0": "0", - "power_step_warm": "Warming", - "power_step_1": "1", - "power_step_2": "1\u2022", - "power_step_3": "2", - "power_step_4": "2\u2022", - "power_step_5": "3", - "power_step_6": "3\u2022", - "power_step_7": "4", - "power_step_8": "4\u2022", - "power_step_9": "5", - "power_step_10": "5\u2022", - "power_step_11": "6", - "power_step_12": "6\u2022", - "power_step_13": "7", - "power_step_14": "7\u2022", - "power_step_15": "8", - "power_step_16": "8\u2022", - "power_step_17": "9", - "power_step_18": "9\u2022", - "power_step_boost": "Boost" + "plate_step_0": "0", + "plate_step_warm": "Warming", + "plate_step_1": "1", + "plate_step_2": "1\u2022", + "plate_step_3": "2", + "plate_step_4": "2\u2022", + "plate_step_5": "3", + "plate_step_6": "3\u2022", + "plate_step_7": "4", + "plate_step_8": "4\u2022", + "plate_step_9": "5", + "plate_step_10": "5\u2022", + "plate_step_11": "6", + "plate_step_12": "6\u2022", + "plate_step_13": "7", + "plate_step_14": "7\u2022", + "plate_step_15": "8", + "plate_step_16": "8\u2022", + "plate_step_17": "9", + "plate_step_18": "9\u2022", + "plate_step_boost": "Boost" } }, "drying_step": { From c0bf167e10b8aaa9f7205072e19b71e950c1bcc5 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:44:01 +0200 Subject: [PATCH 0843/1118] Update `denonavr` to `1.1.2` (#149842) --- homeassistant/components/denonavr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index c5a1b9aeb63..8fea21b707e 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==1.1.1"], + "requirements": ["denonavr==1.1.2"], "ssdp": [ { "manufacturer": "Denon", diff --git a/requirements_all.txt b/requirements_all.txt index e381f0f4363..eede7860c51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -791,7 +791,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denonavr -denonavr==1.1.1 +denonavr==1.1.2 # homeassistant.components.devialet devialet==1.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee4a582885d..6ef659032dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -691,7 +691,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denonavr -denonavr==1.1.1 +denonavr==1.1.2 # homeassistant.components.devialet devialet==1.5.7 From 3e615fd373ac950b9d50a2eb15e49a171c50b14b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:51:08 +0200 Subject: [PATCH 0844/1118] Improve code quality for garage door modules in homematicip_cloud (#149856) --- .../components/homematicip_cloud/cover.py | 8 +-- .../homematicip_cloud/test_cover.py | 52 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 931b689fb08..e846a360d39 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -283,19 +283,19 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" - return self._device.doorState == DoorState.CLOSED + return self.functional_channel.doorState == DoorState.CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._device.send_door_command_async(DoorCommand.OPEN) + await self.functional_channel.async_send_door_command(DoorCommand.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._device.send_door_command_async(DoorCommand.CLOSE) + await self.functional_channel.async_send_door_command(DoorCommand.CLOSE) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._device.send_door_command_async(DoorCommand.STOP) + await self.functional_channel.async_send_door_command(DoorCommand.STOP) class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index b005090309b..9b152988c24 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -365,14 +365,16 @@ async def test_hmip_garage_door_tormatic( assert ha_state.state == "closed" assert ha_state.attributes["current_position"] == 0 - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -381,9 +383,11 @@ async def test_hmip_garage_door_tormatic( await hass.services.async_call( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.CLOSED @@ -392,9 +396,11 @@ async def test_hmip_garage_door_tormatic( await hass.services.async_call( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,) async def test_hmip_garage_door_hoermann( @@ -414,14 +420,16 @@ async def test_hmip_garage_door_hoermann( assert ha_state.state == "closed" assert ha_state.attributes["current_position"] == 0 - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "cover", "open_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.OPEN,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.OPEN,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.OPEN) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.OPEN @@ -430,9 +438,11 @@ async def test_hmip_garage_door_hoermann( await hass.services.async_call( "cover", "close_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.CLOSE,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.CLOSE,) await async_manipulate_test_data(hass, hmip_device, "doorState", DoorState.CLOSED) ha_state = hass.states.get(entity_id) assert ha_state.state == CoverState.CLOSED @@ -441,9 +451,11 @@ async def test_hmip_garage_door_hoermann( await hass.services.async_call( "cover", "stop_cover", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 5 - assert hmip_device.mock_calls[-1][0] == "send_door_command_async" - assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_send_door_command" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == (DoorCommand.STOP,) async def test_hmip_cover_shutter_group( From 7dd2b9e4221edb7f1b1e8d773abe73cd6d8d512c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 3 Aug 2025 03:54:19 +1000 Subject: [PATCH 0845/1118] Make history coordinator more reliable in Tesla Fleet (#149854) --- homeassistant/components/tesla_fleet/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 20d2d70b5dc..e3a31a2c0dc 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -247,11 +247,15 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any raise UpdateFailed(e.message) from e self.updated_once = True + if not data or not isinstance(data.get("time_series"), list): + raise UpdateFailed("Received invalid data") + # Add all time periods together output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: - output[key] += period.get(key, 0) + if key in period: + output[key] += period[key] return output From 018197e41a5972fc6e0c21da633b7de7803e4696 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:55:45 +0200 Subject: [PATCH 0846/1118] Add notifiers to send direct messages to friends in PlayStation Network (#149844) --- .../components/playstation_network/notify.py | 95 +++++++++++++++---- .../playstation_network/strings.json | 3 + .../snapshots/test_notify.ambr | 49 ++++++++++ .../playstation_network/test_notify.py | 11 ++- 4 files changed, 134 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py index 872ad98a594..a06359ebffc 100644 --- a/homeassistant/components/playstation_network/notify.py +++ b/homeassistant/components/playstation_network/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import StrEnum +from typing import TYPE_CHECKING from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, @@ -10,12 +11,14 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPNotFoundError, PSNAWPServerError, ) +from psnawp_api.models.group.group import Group from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, NotifyEntity, NotifyEntityDescription, ) +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -24,6 +27,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ( PlaystationNetworkConfigEntry, + PlaystationNetworkFriendDataCoordinator, PlaystationNetworkGroupsUpdateCoordinator, ) from .entity import PlaystationNetworkServiceEntity @@ -35,6 +39,7 @@ class PlaystationNetworkNotify(StrEnum): """PlayStation Network sensors.""" GROUP_MESSAGE = "group_message" + DIRECT_MESSAGE = "direct_message" async def async_setup_entry( @@ -45,6 +50,7 @@ async def async_setup_entry( """Set up the notify entity platform.""" coordinator = config_entry.runtime_data.groups + groups_added: set[str] = set() entity_registry = er.async_get(hass) @@ -72,8 +78,50 @@ async def async_setup_entry( coordinator.async_add_listener(add_entities) add_entities() + for subentry_id, friend_coordinator in config_entry.runtime_data.friends.items(): + async_add_entities( + [ + PlaystationNetworkDirectMessageNotifyEntity( + friend_coordinator, + config_entry.subentries[subentry_id], + ) + ], + config_subentry_id=subentry_id, + ) -class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity): + +class PlaystationNetworkNotifyBaseEntity(PlaystationNetworkServiceEntity, NotifyEntity): + """Base class of PlayStation Network notify entity.""" + + group: Group | None = None + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + if TYPE_CHECKING: + assert self.group + try: + self.group.send_message(message) + except PSNAWPNotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="group_invalid", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except PSNAWPForbiddenError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except (PSNAWPServerError, PSNAWPClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders=dict(self.translation_placeholders), + ) from e + + +class PlaystationNetworkNotifyEntity(PlaystationNetworkNotifyBaseEntity): """Representation of a PlayStation Network notify entity.""" coordinator: PlaystationNetworkGroupsUpdateCoordinator @@ -101,26 +149,31 @@ class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEnti super().__init__(coordinator, self.entity_description) + +class PlaystationNetworkDirectMessageNotifyEntity(PlaystationNetworkNotifyBaseEntity): + """Representation of a PlayStation Network notify entity for sending direct messages.""" + + coordinator: PlaystationNetworkFriendDataCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkFriendDataCoordinator, + subentry: ConfigSubentry, + ) -> None: + """Initialize a notification entity.""" + + self.entity_description = NotifyEntityDescription( + key=PlaystationNetworkNotify.DIRECT_MESSAGE, + translation_key=PlaystationNetworkNotify.DIRECT_MESSAGE, + ) + + super().__init__(coordinator, self.entity_description, subentry) + def send_message(self, message: str, title: str | None = None) -> None: """Send a message.""" - try: - self.group.send_message(message) - except PSNAWPNotFoundError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="group_invalid", - translation_placeholders=dict(self.translation_placeholders), - ) from e - except PSNAWPForbiddenError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_message_forbidden", - translation_placeholders=dict(self.translation_placeholders), - ) from e - except (PSNAWPServerError, PSNAWPClientError) as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_message_failed", - translation_placeholders=dict(self.translation_placeholders), - ) from e + if not self.group: + self.group = self.coordinator.psn.psn.group( + users_list=[self.coordinator.user] + ) + super().send_message(message, title) diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index e5192f42873..26a1b336e2d 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -158,6 +158,9 @@ "notify": { "group_message": { "name": "Group: {group_name}" + }, + "direct_message": { + "name": "Direct message" } } } diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr index 60525925787..d8c32918433 100644 --- a/tests/components/playstation_network/snapshots/test_notify.ambr +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_notify_platform[notify.testuser_direct_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_direct_message', + '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': 'Direct message', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'fren-psn-id_direct_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_direct_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Direct message', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_direct_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_notify_platform[notify.testuser_group_publicuniversalfriend-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py index ebaac37a09f..f81e03dfcc4 100644 --- a/tests/components/playstation_network/test_notify.py +++ b/tests/components/playstation_network/test_notify.py @@ -55,11 +55,16 @@ async def test_notify_platform( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + "entity_id", + ["notify.testuser_group_publicuniversalfriend", "notify.testuser_direct_message"], +) @freeze_time("2025-07-28T00:00:00+00:00") async def test_send_message( hass: HomeAssistant, config_entry: MockConfigEntry, mock_psnawpapi: MagicMock, + entity_id: str, ) -> None: """Test send message.""" @@ -69,7 +74,7 @@ async def test_send_message( assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get("notify.testuser_group_publicuniversalfriend") + state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -77,13 +82,13 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_ENTITY_ID: entity_id, ATTR_MESSAGE: "henlo fren", }, blocking=True, ) - state = hass.states.get("notify.testuser_group_publicuniversalfriend") + state = hass.states.get(entity_id) assert state assert state.state == "2025-07-28T00:00:00+00:00" mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") From fa476d4e34ff2a74477215135979902162a01198 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:01:02 +0100 Subject: [PATCH 0847/1118] Fix initialisation of Apps and Radios list for Squeezebox (#149834) --- .../components/squeezebox/browse_media.py | 52 ++++++++++++++----- .../components/squeezebox/media_player.py | 5 ++ .../squeezebox/test_media_player.py | 8 +-- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index bab4f90c6d1..4f2a1fa7aa5 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -4,6 +4,7 @@ from __future__ import annotations import contextlib from dataclasses import dataclass, field +import logging from typing import Any from pysqueezebox import Player @@ -21,6 +22,8 @@ from homeassistant.helpers.network import is_internal_request from .const import DOMAIN, UNPLAYABLE_TYPES +_LOGGER = logging.getLogger(__name__) + LIBRARY = [ "favorites", "artists", @@ -138,18 +141,42 @@ class BrowseData: self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + def add_new_command(self, cmd: str | MediaType, type: str) -> None: + """Add items to maps for new apps or radios.""" + self.known_apps_radios.add(cmd) + self.media_type_to_squeezebox[cmd] = cmd + self.squeezebox_id_by_type[cmd] = type + self.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + self.content_type_to_child_type[cmd] = MediaType.TRACK -def _add_new_command_to_browse_data( - browse_data: BrowseData, cmd: str | MediaType, type: str -) -> None: - """Add items to maps for new apps or radios.""" - browse_data.media_type_to_squeezebox[cmd] = cmd - browse_data.squeezebox_id_by_type[cmd] = type - browse_data.content_type_media_class[cmd] = { - "item": MediaClass.DIRECTORY, - "children": MediaClass.TRACK, - } - browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + async def async_init(self, player: Player, browse_limit: int) -> None: + """Initialize known apps and radios from the player.""" + + cmd = ["apps", 0, browse_limit] + result = await player.async_query(*cmd) + for app in result["appss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) + cmd = ["radios", 0, browse_limit] + result = await player.async_query(*cmd) + for app in result["radioss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) def _build_response_apps_radios_category( @@ -292,8 +319,7 @@ async def build_item_response( app_cmd = "app-" + item["cmd"] if app_cmd not in browse_data.known_apps_radios: - browse_data.known_apps_radios.add(app_cmd) - _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + browse_data.add_new_command(app_cmd, "item_id") child_media = _build_response_apps_radios_category( browse_data=browse_data, cmd=app_cmd, item=item diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0dbc1b96b0c..49aad4fd698 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -311,6 +311,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): ) return None + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + await self._browse_data.async_init(self._player, self.browse_limit) + async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" self.coordinator.config_entry.runtime_data.known_player_ids.remove( diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 440f682370b..6e3e5be0459 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -765,9 +765,7 @@ async def test_squeezebox_call_query( }, blocking=True, ) - configured_player.async_query.assert_called_once_with( - "test_command", "param1", "param2" - ) + configured_player.async_query.assert_called_with("test_command", "param1", "param2") async def test_squeezebox_call_method( @@ -784,9 +782,7 @@ async def test_squeezebox_call_method( }, blocking=True, ) - configured_player.async_query.assert_called_once_with( - "test_command", "param1", "param2" - ) + configured_player.async_query.assert_called_with("test_command", "param1", "param2") async def test_squeezebox_invalid_state( From 755864f9f3b0d0465f283b82393db7d14b38b397 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 2 Aug 2025 20:01:58 +0200 Subject: [PATCH 0848/1118] Add sensor platform to Qbus integration (#149389) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/qbus/climate.py | 6 +- homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/cover.py | 6 +- homeassistant/components/qbus/entity.py | 56 +- homeassistant/components/qbus/light.py | 6 +- homeassistant/components/qbus/manifest.json | 1 + homeassistant/components/qbus/scene.py | 13 +- homeassistant/components/qbus/sensor.py | 378 ++++++ homeassistant/components/qbus/strings.json | 16 + homeassistant/components/qbus/switch.py | 6 +- tests/components/qbus/conftest.py | 21 +- .../qbus/fixtures/payload_config.json | 338 +++++- .../qbus/snapshots/test_sensor.ambr | 1047 +++++++++++++++++ tests/components/qbus/test_sensor.py | 27 + 14 files changed, 1882 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/qbus/sensor.py create mode 100644 tests/components/qbus/snapshots/test_sensor.ambr create mode 100644 tests/components/qbus/test_sensor.py diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py index caaec2f95d7..a19ec4d0156 100644 --- a/homeassistant/components/qbus/climate.py +++ b/homeassistant/components/qbus/climate.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -42,13 +42,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "thermo", QbusClimate, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index 73819d2a11b..133a3b8fea9 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -10,6 +10,7 @@ PLATFORMS: list[Platform] = [ Platform.COVER, Platform.LIGHT, Platform.SCENE, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/cover.py b/homeassistant/components/qbus/cover.py index 2adb8253551..3fc1b20602a 100644 --- a/homeassistant/components/qbus/cover.py +++ b/homeassistant/components/qbus/cover.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -36,13 +36,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "shutter", QbusCover, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 91e4d83b548..9fb481d4515 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -14,7 +14,6 @@ from qbusmqttapi.state import QbusMqttState from homeassistant.components.mqtt import ReceiveMessage, client as mqtt from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER from .coordinator import QbusControllerCoordinator @@ -24,14 +23,24 @@ _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") StateT = TypeVar("StateT", bound=QbusMqttState) -def add_new_outputs( +def create_new_entities( coordinator: QbusControllerCoordinator, added_outputs: list[QbusMqttOutput], filter_fn: Callable[[QbusMqttOutput], bool], entity_type: type[QbusEntity], - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Call async_add_entities for new outputs.""" +) -> list[QbusEntity]: + """Create entities for new outputs.""" + + new_outputs = determine_new_outputs(coordinator, added_outputs, filter_fn) + return [entity_type(output) for output in new_outputs] + + +def determine_new_outputs( + coordinator: QbusControllerCoordinator, + added_outputs: list[QbusMqttOutput], + filter_fn: Callable[[QbusMqttOutput], bool], +) -> list[QbusMqttOutput]: + """Determine new outputs.""" added_ref_ids = {k.ref_id for k in added_outputs} @@ -43,7 +52,8 @@ def add_new_outputs( if new_outputs: added_outputs.extend(new_outputs) - async_add_entities([entity_type(output) for output in new_outputs]) + + return new_outputs def format_ref_id(ref_id: str) -> str | None: @@ -67,7 +77,13 @@ class QbusEntity(Entity, Generic[StateT], ABC): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, mqtt_output: QbusMqttOutput) -> None: + def __init__( + self, + mqtt_output: QbusMqttOutput, + *, + id_suffix: str = "", + link_to_main_device: bool = False, + ) -> None: """Initialize the Qbus entity.""" self._mqtt_output = mqtt_output @@ -79,17 +95,25 @@ class QbusEntity(Entity, Generic[StateT], ABC): ) ref_id = format_ref_id(mqtt_output.ref_id) + unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" - self._attr_unique_id = f"ctd_{mqtt_output.device.serial_number}_{ref_id}" + if id_suffix: + unique_id += f"_{id_suffix}" - # Create linked device - self._attr_device_info = DeviceInfo( - name=mqtt_output.name.title(), - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, - suggested_area=mqtt_output.location.title(), - via_device=create_main_device_identifier(mqtt_output), - ) + self._attr_unique_id = unique_id + + if link_to_main_device: + self._attr_device_info = DeviceInfo( + identifiers={create_main_device_identifier(mqtt_output)} + ) + else: + self._attr_device_info = DeviceInfo( + name=mqtt_output.name.title(), + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, f"{mqtt_output.device.serial_number}_{ref_id}")}, + suggested_area=mqtt_output.location.title(), + via_device=create_main_device_identifier(mqtt_output), + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 4385cfe60f0..61225f11243 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -27,13 +27,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "analog", QbusLight, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index feffa6e492c..15392f6cc97 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/qbus", "integration_type": "hub", "iot_class": "local_push", + "loggers": ["qbusmqttapi"], "mqtt": [ "cloudapp/QBUSMQTTGW/state", "cloudapp/QBUSMQTTGW/config", diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py index 8d18feb26d3..706fb089dde 100644 --- a/homeassistant/components/qbus/scene.py +++ b/homeassistant/components/qbus/scene.py @@ -7,11 +7,10 @@ from qbusmqttapi.state import QbusMqttState, StateAction, StateType from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs, create_main_device_identifier +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -27,13 +26,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "scene", QbusScene, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -45,12 +44,8 @@ class QbusScene(QbusEntity, Scene): def __init__(self, mqtt_output: QbusMqttOutput) -> None: """Initialize scene entity.""" - super().__init__(mqtt_output) + super().__init__(mqtt_output, link_to_main_device=True) - # Add to main controller device - self._attr_device_info = DeviceInfo( - identifiers={create_main_device_identifier(mqtt_output)} - ) self._attr_name = mqtt_output.name.title() async def async_activate(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/qbus/sensor.py b/homeassistant/components/qbus/sensor.py new file mode 100644 index 00000000000..e983e0a8cbb --- /dev/null +++ b/homeassistant/components/qbus/sensor.py @@ -0,0 +1,378 @@ +"""Support for Qbus sensor.""" + +from dataclasses import dataclass + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import ( + GaugeStateProperty, + QbusMqttGaugeState, + QbusMqttHumidityState, + QbusMqttThermoState, + QbusMqttVentilationState, + QbusMqttWeatherState, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, create_new_entities, determine_new_outputs + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class QbusWeatherDescription(SensorEntityDescription): + """Description for Qbus weather entities.""" + + property: str + + +_WEATHER_DESCRIPTIONS = ( + QbusWeatherDescription( + key="daylight", + property="dayLight", + translation_key="daylight", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light", + property="light", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_east", + property="lightEast", + translation_key="light_east", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_south", + property="lightSouth", + translation_key="light_south", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="light_west", + property="lightWest", + translation_key="light_west", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + ), + QbusWeatherDescription( + key="temperature", + property="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + QbusWeatherDescription( + key="wind", + property="wind", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + ), +) + +_GAUGE_VARIANT_DESCRIPTIONS = { + "AIRPRESSURE": SensorEntityDescription( + key="airpressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + ), + "AIRQUALITY": SensorEntityDescription( + key="airquality", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + ), + "CURRENT": SensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ENERGY": SensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + ), + "GAS": SensorEntityDescription( + key="gas", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "GASFLOW": SensorEntityDescription( + key="gasflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "HUMIDITY": SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "LIGHT": SensorEntityDescription( + key="light", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + ), + "LOUDNESS": SensorEntityDescription( + key="loudness", + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + state_class=SensorStateClass.MEASUREMENT, + ), + "POWER": SensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + "PRESSURE": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + ), + "TEMPERATURE": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + "VOLTAGE": SensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + "VOLUME": SensorEntityDescription( + key="volume", + device_class=SensorDeviceClass.VOLUME_STORAGE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATER": SensorEntityDescription( + key="water", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.TOTAL, + ), + "WATERFLOW": SensorEntityDescription( + key="waterflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATERLEVEL": SensorEntityDescription( + key="waterlevel", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + "WATERPRESSURE": SensorEntityDescription( + key="waterpressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + ), + "WIND": SensorEntityDescription( + key="wind", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +def _is_gauge_with_variant(output: QbusMqttOutput) -> bool: + return ( + output.type == "gauge" + and isinstance(output.variant, str) + and _GAUGE_VARIANT_DESCRIPTIONS.get(output.variant.upper()) is not None + ) + + +def _is_ventilation_with_co2(output: QbusMqttOutput) -> bool: + return output.type == "ventilation" and output.properties.get("co2") is not None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _create_weather_entities() -> list[QbusEntity]: + new_outputs = determine_new_outputs( + coordinator, added_outputs, lambda output: output.type == "weatherstation" + ) + + return [ + QbusWeatherSensor(output, description) + for output in new_outputs + for description in _WEATHER_DESCRIPTIONS + ] + + def _check_outputs() -> None: + entities: list[QbusEntity] = [ + *create_new_entities( + coordinator, + added_outputs, + _is_gauge_with_variant, + QbusGaugeVariantSensor, + ), + *create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "humidity", + QbusHumiditySensor, + ), + *create_new_entities( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusThermoSensor, + ), + *create_new_entities( + coordinator, + added_outputs, + _is_ventilation_with_co2, + QbusVentilationSensor, + ), + *_create_weather_entities(), + ] + + async_add_entities(entities) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusGaugeVariantSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for gauges with variant.""" + + _state_cls = QbusMqttGaugeState + + _attr_name = None + _attr_suggested_display_precision = 2 + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize sensor entity.""" + + super().__init__(mqtt_output) + + variant = str(mqtt_output.variant) + self.entity_description = _GAUGE_VARIANT_DESCRIPTIONS[variant.upper()] + + async def _handle_state_received(self, state: QbusMqttGaugeState) -> None: + self._attr_native_value = state.read_value(GaugeStateProperty.CURRENT_VALUE) + + +class QbusHumiditySensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for humidity modules.""" + + _state_cls = QbusMqttHumidityState + + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_name = None + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + async def _handle_state_received(self, state: QbusMqttHumidityState) -> None: + self._attr_native_value = state.read_value() + + +class QbusThermoSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for thermostats.""" + + _state_cls = QbusMqttThermoState + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + + async def _handle_state_received(self, state: QbusMqttThermoState) -> None: + self._attr_native_value = state.read_current_temperature() + + +class QbusVentilationSensor(QbusEntity, SensorEntity): + """Representation of a Qbus sensor entity for ventilations.""" + + _state_cls = QbusMqttVentilationState + + _attr_device_class = SensorDeviceClass.CO2 + _attr_name = None + _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + async def _handle_state_received(self, state: QbusMqttVentilationState) -> None: + self._attr_native_value = state.read_co2() + + +class QbusWeatherSensor(QbusEntity, SensorEntity): + """Representation of a Qbus weather sensor.""" + + _state_cls = QbusMqttWeatherState + + entity_description: QbusWeatherDescription + + def __init__( + self, mqtt_output: QbusMqttOutput, description: QbusWeatherDescription + ) -> None: + """Initialize sensor entity.""" + + super().__init__(mqtt_output, id_suffix=description.key) + + self.entity_description = description + + if description.key == "temperature": + self._attr_name = None + + async def _handle_state_received(self, state: QbusMqttWeatherState) -> None: + if value := state.read_property(self.entity_description.property, None): + self.native_value = value diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index f308c5b3519..f3a0d108476 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -16,6 +16,22 @@ "no_controller": "No controllers were found" } }, + "entity": { + "sensor": { + "daylight": { + "name": "Daylight" + }, + "light_east": { + "name": "Illuminance east" + }, + "light_south": { + "name": "Illuminance south" + }, + "light_west": { + "name": "Illuminance west" + } + } + }, "exceptions": { "invalid_preset": { "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 05283a44cfc..3c4d280fa30 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity, add_new_outputs +from .entity import QbusEntity, create_new_entities PARALLEL_UPDATES = 0 @@ -26,13 +26,13 @@ async def async_setup_entry( added_outputs: list[QbusMqttOutput] = [] def _check_outputs() -> None: - add_new_outputs( + entities = create_new_entities( coordinator, added_outputs, lambda output: output.type == "onoff", QbusSwitch, - async_add_entities, ) + async_add_entities(entities) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index 9b42a6a3de8..e1febea524b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for qbus.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator import json from unittest.mock import AsyncMock, patch @@ -64,3 +64,22 @@ async def setup_integration( async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) await hass.async_block_till_done() + + +@pytest.fixture +async def setup_integration_deferred( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> Callable[[], Awaitable]: + """Set up the integration.""" + + async def run() -> None: + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + return run diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index 2cad6c623db..883eca19276 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -116,7 +116,7 @@ { "id": "UL30", "location": "Guest bedroom", - "locationId": 0, + "locationId": 3, "name": "CURTAINS", "originalName": "CURTAINS", "refId": "000001/108", @@ -144,7 +144,7 @@ }, "id": "UL31", "location": "Living", - "locationId": 8, + "locationId": 0, "name": "SLATS", "originalName": "SLATS", "properties": { @@ -183,6 +183,340 @@ }, "refId": "000001/4", "type": "shutter" + }, + { + "id": "UL40", + "location": "Tuin", + "locationId": 12, + "name": "Luchtdruk", + "originalName": "Luchtdruk", + "refId": "000001/81", + "type": "gauge", + "variant": "AirPressure", + "actions": {}, + "properties": { + "currentValue": { + "max": 1500, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "mbar", + "write": false + } + } + }, + { + "id": "UL41", + "location": "Tuin", + "locationId": 12, + "name": "Luchtkwaliteit", + "originalName": "Luchtkwaliteit", + "refId": "000001/82", + "type": "gauge", + "variant": "AirQuality", + "actions": {}, + "properties": { + "currentValue": { + "max": 1500, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "ppm", + "write": false + } + } + }, + { + "id": "UL42", + "location": "Garage", + "locationId": 27, + "name": "Stroom", + "originalName": "Stroom", + "refId": "000001/83", + "type": "gauge", + "variant": "Current", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "kWh", + "write": false + } + } + }, + { + "id": "UL43", + "location": "Garage", + "locationId": 27, + "name": "Energie", + "originalName": "Energie", + "refId": "000001/84", + "type": "gauge", + "variant": "Energy", + "actions": {}, + "properties": { + "currentValue": { + "read": true, + "step": 0.1, + "type": "number", + "unit": "A", + "write": false + } + } + }, + { + "id": "UL44", + "location": "Garage", + "locationId": 27, + "name": "Gas", + "originalName": "Gas", + "refId": "000001/85", + "type": "gauge", + "variant": "Gas", + "actions": {}, + "properties": { + "currentValue": { + "max": 5, + "min": 0, + "read": true, + "step": 0.001, + "type": "number", + "unit": "m³/h", + "write": false + } + } + }, + { + "id": "UL45", + "location": "Garage", + "locationId": 27, + "name": "Gas flow", + "originalName": "Gas flow", + "refId": "000001/86", + "type": "gauge", + "variant": "GasFlow", + "actions": {}, + "properties": { + "currentValue": { + "max": 10, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "m³/h", + "write": false + } + } + }, + { + "id": "UL46", + "location": "Living", + "locationId": 0, + "name": "Vochtigheid living", + "originalName": "Vochtigheid living", + "refId": "000001/87", + "type": "gauge", + "variant": "Humidity", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "%", + "write": false + } + } + }, + { + "id": "UL47", + "location": "Tuin", + "locationId": 12, + "name": "Lichtsterkte tuin", + "originalName": "Lichtsterkte tuin", + "refId": "000001/88", + "type": "gauge", + "variant": "Light", + "actions": {}, + "properties": { + "currentValue": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "lx", + "write": false + } + } + }, + { + "id": "UL40", + "location": "Tuin", + "locationId": 12, + "name": "Regenput", + "originalName": "Regenput", + "refId": "000001/40", + "type": "gauge", + "variant": "WaterLevel", + "actions": {}, + "properties": { + "currentValue": { + "max": 100, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "unit": "m", + "write": false + } + } + }, + { + "id": "UL60", + "location": "Tuin", + "locationId": 12, + "name": "Weersensor", + "originalName": "Weersensor", + "refId": "000001/21007", + "type": "weatherstation", + "variant": [null], + "actions": {}, + "properties": { + "dayLight": { + "max": 1000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "light": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightEast": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightSouth": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "lightWest": { + "max": 100000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "raining": { + "read": true, + "type": "boolean", + "write": false + }, + "temperature": { + "max": 100, + "min": -100, + "read": true, + "step": 0.1, + "type": "number", + "write": false + }, + "twilight": { + "read": true, + "type": "boolean", + "write": false + }, + "wind": { + "max": 1000, + "min": 0, + "read": true, + "step": 0.1, + "type": "number", + "write": false + } + } + }, + { + "id": "UL70", + "location": "", + "locationId": 0, + "name": "Luchtsensor", + "originalName": "Luchtsensor", + "refId": "000001/224", + "type": "ventilation", + "variant": [null], + "actions": {}, + "properties": { + "co2": { + "max": 5000, + "min": 0, + "read": true, + "step": 16, + "type": "number", + "unit": "ppm", + "write": false + }, + "currRegime": { + "enumValues": ["Manueel", "Nacht", "Boost", "Uit", "Auto"], + "read": true, + "type": "enumString", + "write": true + }, + "refresh": { + "max": 100, + "min": 0, + "read": true, + "step": 1, + "type": "number", + "write": true + } + } + }, + { + "id": "UL80", + "location": "Kitchen", + "locationId": 8, + "name": "Vochtigheid keuken", + "originalName": "Vochtigheid keuken", + "properties": { + "currRegime": { + "enumValues": ["Manual", "Cook", "Boost", "Off", "Auto"], + "read": true, + "type": "enumString", + "write": true + }, + "value": { + "read": true, + "step": 1, + "type": "percent", + "write": false + } + }, + "refId": "000001/94/1", + "type": "humidity" } ] } diff --git a/tests/components/qbus/snapshots/test_sensor.ambr b/tests/components/qbus/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fe665057b1e --- /dev/null +++ b/tests/components/qbus/snapshots/test_sensor.ambr @@ -0,0 +1,1047 @@ +# serializer version: 1 +# name: test_sensor[sensor.energie-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.energie', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_84', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.energie-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energie', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energie', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.gas-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.gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_85', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.gas_flow-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.gas_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_86', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.gas_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Gas Flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.lichtsterkte_tuin-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.lichtsterkte_tuin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_88', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.lichtsterkte_tuin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Lichtsterkte Tuin', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.lichtsterkte_tuin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.living_th_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.living_th_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_120', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.living_th_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Living Th Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.living_th_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtdruk-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.luchtdruk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_81', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.luchtdruk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Luchtdruk', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luchtdruk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtkwaliteit-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.luchtkwaliteit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_82', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor[sensor.luchtkwaliteit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Luchtkwaliteit', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.luchtkwaliteit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.luchtsensor-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.luchtsensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_224', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor[sensor.luchtsensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Luchtsensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.luchtsensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.regenput-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.regenput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_40', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.regenput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Regenput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.regenput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.stroom-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.stroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_83', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.stroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Stroom', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.stroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.vochtigheid_keuken-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.vochtigheid_keuken', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_94-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.vochtigheid_keuken-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Vochtigheid Keuken', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vochtigheid_keuken', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.vochtigheid_living-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.vochtigheid_living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_87', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.vochtigheid_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Vochtigheid Living', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.vochtigheid_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor-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.weersensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.weersensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Weersensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weersensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_daylight-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.weersensor_daylight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daylight', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'daylight', + 'unique_id': 'ctd_000001_21007_daylight', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_daylight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Daylight', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_daylight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance-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.weersensor_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_east-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.weersensor_illuminance_east', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance east', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_east', + 'unique_id': 'ctd_000001_21007_light_east', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_east-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance east', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_east', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_south-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.weersensor_illuminance_south', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance south', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_south', + 'unique_id': 'ctd_000001_21007_light_south', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_south-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance south', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_south', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_west-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.weersensor_illuminance_west', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance west', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_west', + 'unique_id': 'ctd_000001_21007_light_west', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.weersensor_illuminance_west-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Weersensor Illuminance west', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.weersensor_illuminance_west', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[sensor.weersensor_wind_speed-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.weersensor_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'qbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ctd_000001_21007_wind', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.weersensor_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Weersensor Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weersensor_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/qbus/test_sensor.py b/tests/components/qbus/test_sensor.py new file mode 100644 index 00000000000..255b29eb7f0 --- /dev/null +++ b/tests/components/qbus/test_sensor.py @@ -0,0 +1,27 @@ +"""Test Qbus sensors.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor( + hass: HomeAssistant, + setup_integration_deferred: Callable[[], Awaitable], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor.""" + + with patch("homeassistant.components.qbus.PLATFORMS", [Platform.SENSOR]): + await setup_integration_deferred() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 72d9dbf39d893fac3704f883d6e8b99057c86dbe Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:17:13 +0200 Subject: [PATCH 0849/1118] Add scopes in config flow auth request for Volvo integration (#149813) --- homeassistant/components/volvo/config_flow.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/volvo/config_flow.py b/homeassistant/components/volvo/config_flow.py index 05d19fd1d26..f187d751a2d 100644 --- a/homeassistant/components/volvo/config_flow.py +++ b/homeassistant/components/volvo/config_flow.py @@ -9,6 +9,7 @@ from typing import Any import voluptuous as vol from volvocarsapi.api import VolvoCarsApi from volvocarsapi.models import VolvoApiException, VolvoCarsVehicle +from volvocarsapi.scopes import DEFAULT_SCOPES from homeassistant.config_entries import ( SOURCE_REAUTH, @@ -54,6 +55,13 @@ class VolvoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): self._vehicles: list[VolvoCarsVehicle] = [] self._config_data: dict = {} + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return super().extra_authorize_data | { + "scope": " ".join(DEFAULT_SCOPES), + } + @property def logger(self) -> logging.Logger: """Return logger.""" From 1236801b7d75ce7ec1b84c702079761760c30b95 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 2 Aug 2025 23:07:16 +0200 Subject: [PATCH 0850/1118] Fix Z-Wave config entry state conditions in listen task (#149841) --- homeassistant/components/zwave_js/__init__.py | 19 ++-- tests/components/zwave_js/conftest.py | 10 +-- tests/components/zwave_js/test_init.py | 86 ++++++++++++++++--- 3 files changed, 88 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 360969e83d4..52a5a1b7388 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -1074,23 +1074,32 @@ async def client_listen( try: await client.listen(driver_ready) except BaseZwaveJSServerError as err: - if entry.state is not ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: raise LOGGER.error("Client listen failed: %s", err) except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) - if entry.state is not ConfigEntryState.LOADED: + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: raise + if hass.is_stopping or entry.state is ConfigEntryState.UNLOAD_IN_PROGRESS: + return + + if entry.state is ConfigEntryState.SETUP_IN_PROGRESS: + raise HomeAssistantError("Listen task ended unexpectedly") + # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. # All model instances will be replaced when the new state is acquired. - if not hass.is_stopping: - if entry.state is not ConfigEntryState.LOADED: - raise HomeAssistantError("Listen task ended unexpectedly") + if entry.state.recoverable: LOGGER.debug("Disconnected from server. Reloading integration") hass.config_entries.async_schedule_reload(entry.entry_id) + else: + LOGGER.error( + "Disconnected from server. Cannot recover entry %s", + entry.title, + ) async def async_unload_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 3c07869d5b7..eef92a7eb0a 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -565,12 +565,6 @@ def mock_listen_block_fixture() -> asyncio.Event: return asyncio.Event() -@pytest.fixture(name="listen_result") -def listen_result_fixture() -> asyncio.Future[None]: - """Mock a listen result.""" - return asyncio.Future() - - @pytest.fixture(name="client") def mock_client_fixture( controller_state: dict[str, Any], @@ -578,7 +572,6 @@ def mock_client_fixture( version_state: dict[str, Any], log_config_state: dict[str, Any], listen_block: asyncio.Event, - listen_result: asyncio.Future[None], ): """Mock a client.""" with patch( @@ -587,15 +580,16 @@ def mock_client_fixture( client = client_class.return_value async def connect(): + listen_block.clear() await asyncio.sleep(0) client.connected = True async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() await listen_block.wait() - await listen_result async def disconnect(): + listen_block.set() client.connected = False client.connect = AsyncMock(side_effect=connect) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index d9b3f392dd6..4decb061ad0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -196,19 +196,24 @@ async def test_listen_done_during_setup_before_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup before forward entry.""" + listen_result = asyncio.Future[None]() assert hass.state is CoreState.running + async def connect(): + await asyncio.sleep(0) + client.connected = True + async def listen(driver_ready: asyncio.Event) -> None: await listen_block.wait() await listen_result async_fire_time_changed(hass, fire_all=True) + client.connect.side_effect = connect client.listen.side_effect = listen hass.set_state(core_state) listen_block.set() @@ -229,9 +234,9 @@ async def test_not_connected_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], ) -> None: """Test we handle not connected client during setup after forward entry.""" + listen_result = asyncio.Future[None]() async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: """Mock send command.""" @@ -277,12 +282,12 @@ async def test_listen_done_during_setup_after_forward_entry( hass: HomeAssistant, client: MagicMock, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, ) -> None: """Test listen task finishing during setup after forward entry.""" + listen_result = asyncio.Future[None]() assert hass.state is CoreState.running original_send_command_side_effect = client.async_send_command.side_effect @@ -320,16 +325,14 @@ async def test_listen_done_during_setup_after_forward_entry( @pytest.mark.parametrize( - ("core_state", "final_config_entry_state", "disconnect_call_count"), + ("core_state", "disconnect_call_count"), [ ( CoreState.running, - ConfigEntryState.SETUP_RETRY, - 2, - ), # the reload will cause a disconnect call too + 1, + ), # the reload will cause a disconnect ( CoreState.stopping, - ConfigEntryState.LOADED, 0, ), # the home assistant stop event will handle the disconnect ], @@ -345,19 +348,33 @@ async def test_listen_done_during_setup_after_forward_entry( async def test_listen_done_after_setup( hass: HomeAssistant, client: MagicMock, - integration: MockConfigEntry, listen_block: asyncio.Event, - listen_result: asyncio.Future[None], core_state: CoreState, listen_future_result_method: str, listen_future_result: Exception | None, - final_config_entry_state: ConfigEntryState, disconnect_call_count: int, ) -> None: """Test listen task finishing after setup.""" - config_entry = integration - assert config_entry.state is ConfigEntryState.LOADED + listen_result = asyncio.Future[None]() + + async def listen(driver_ready: asyncio.Event) -> None: + driver_ready.set() + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + + config_entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.state is CoreState.running + assert config_entry.state is ConfigEntryState.LOADED assert client.disconnect.call_count == 0 hass.set_state(core_state) @@ -365,10 +382,51 @@ async def test_listen_done_after_setup( getattr(listen_result, listen_future_result_method)(listen_future_result) await hass.async_block_till_done() - assert config_entry.state is final_config_entry_state + assert config_entry.state is ConfigEntryState.LOADED assert client.disconnect.call_count == disconnect_call_count +async def test_listen_ending_before_cancelling_listen( + hass: HomeAssistant, + integration: MockConfigEntry, + listen_block: asyncio.Event, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test listen ending during unloading before cancelling the listen task.""" + config_entry = integration + + # We can't easily simulate the race condition where the listen task ends + # before getting cancelled by the config entry during unloading. + # Use mock_state to provoke the correct condition. + config_entry.mock_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) + listen_block.set() + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.UNLOAD_IN_PROGRESS + assert not any(record.levelno == logging.ERROR for record in caplog.records) + + +async def test_listen_ending_unrecoverable_config_entry_state( + hass: HomeAssistant, + integration: MockConfigEntry, + listen_block: asyncio.Event, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test listen ending when the config entry has an unrecoverable state.""" + config_entry = integration + + with patch.object( + hass.config_entries, "async_unload_platforms", return_value=False + ): + await hass.config_entries.async_unload(config_entry.entry_id) + + listen_block.set() + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.FAILED_UNLOAD + assert "Disconnected from server. Cannot recover entry" in caplog.text + + @pytest.mark.usefixtures("client") @pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_new_entity_on_value_added( From 08f7b708a453f9a791fb2070f3b0f827c88bb547 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 3 Aug 2025 09:25:17 +0200 Subject: [PATCH 0851/1118] Update pytest warnings filter (#149839) --- pyproject.toml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71182e99560..2bcb7787601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -487,19 +487,10 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", # -- fixed, waiting for release / update - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:.*invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:UserWarning:datadog.util.compat", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", # https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:reactivex.internal.constants", - # https://github.com/postlund/pyatv/issues/2645 - >0.16.0 - # https://github.com/postlund/pyatv/pull/2664 - "ignore:Protobuf gencode .* exactly one major version older than the runtime version 6.* at pyatv:UserWarning:google.protobuf.runtime_version", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", @@ -526,6 +517,9 @@ filterwarnings = [ "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12 + # https://github.com/LennP/motionblindsble/blob/0.1.3/motionblindsble/device.py#L390 + "ignore:Passing additional arguments for BLEDevice is deprecated and has no effect:DeprecationWarning:motionblindsble.device", # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", @@ -542,8 +536,6 @@ filterwarnings = [ "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 "ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", @@ -589,7 +581,7 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 + # https://github.com/graphql-python/gql/pull/543 - >=4.0.0b0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", # -- unmaintained projects, last release about 2+ years From b2349ac2bdd0404ee73d7f4c918e3bff6b9d40e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 3 Aug 2025 11:19:08 +0200 Subject: [PATCH 0852/1118] Improve miele climate test coverage (#149859) --- .../components/miele/fixtures/5_devices.json | 124 +++++++++++ .../miele/fixtures/action_fridge_freezer.json | 31 +++ .../miele/fixtures/fridge_freezer.json | 9 +- .../miele/snapshots/test_climate.ambr | 208 +++++++++++++++++- tests/components/miele/test_climate.py | 31 ++- 5 files changed, 383 insertions(+), 20 deletions(-) create mode 100644 tests/components/miele/fixtures/action_fridge_freezer.json diff --git a/tests/components/miele/fixtures/5_devices.json b/tests/components/miele/fixtures/5_devices.json index 113babbd3f7..2e76c1f6ef5 100644 --- a/tests/components/miele/fixtures/5_devices.json +++ b/tests/components/miele/fixtures/5_devices.json @@ -648,5 +648,129 @@ }, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/action_fridge_freezer.json b/tests/components/miele/fixtures/action_fridge_freezer.json new file mode 100644 index 00000000000..94ee43a90fe --- /dev/null +++ b/tests/components/miele/fixtures/action_fridge_freezer.json @@ -0,0 +1,31 @@ +{ + "processAction": [6], + "light": [], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [ + { + "zone": 1, + "min": 1, + "max": 9 + }, + { + "zone": 2, + "min": -28, + "max": -14 + }, + { + "zone": 3, + "min": -30, + "max": -15 + } + ], + "deviceName": true, + "powerOn": false, + "powerOff": false, + "colors": [], + "modes": [1], + "runOnTime": [] +} diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json index 5d091b9c74e..8ca28befc35 100644 --- a/tests/components/miele/fixtures/fridge_freezer.json +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -53,6 +53,11 @@ "value_raw": -1800, "value_localized": -18.0, "unit": "Celsius" + }, + { + "value_raw": -2500, + "value_localized": -25.0, + "unit": "Celsius" } ], "coreTargetTemperature": [], @@ -68,8 +73,8 @@ "unit": "Celsius" }, { - "value_raw": -32768, - "value_localized": null, + "value_raw": -2800, + "value_localized": -28.0, "unit": "Celsius" } ], diff --git a/tests/components/miele/snapshots/test_climate.ambr b/tests/components/miele/snapshots/test_climate.ambr index 0fb24c893c4..3b8b7488d9b 100644 --- a/tests/components/miele/snapshots/test_climate.ambr +++ b/tests/components/miele/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_climate_states[platforms0-freezer][climate.freezer-entry] +# name: test_climate_states[freezer-platforms0][climate.freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -41,7 +41,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states[platforms0-freezer][climate.freezer-state] +# name: test_climate_states[freezer-platforms0][climate.freezer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': -18, @@ -63,7 +63,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states[platforms0-freezer][climate.refrigerator-entry] +# name: test_climate_states[freezer-platforms0][climate.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -105,7 +105,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states[platforms0-freezer][climate.refrigerator-state] +# name: test_climate_states[freezer-platforms0][climate.refrigerator-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 4, @@ -127,7 +127,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-entry] +# name: test_climate_states_api_push[freezer-platforms0][climate.freezer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -169,7 +169,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.freezer-state] +# name: test_climate_states_api_push[freezer-platforms0][climate.freezer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': -18, @@ -191,7 +191,7 @@ 'state': 'cool', }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.refrigerator-entry] +# name: test_climate_states_api_push[freezer-platforms0][climate.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -233,7 +233,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_states_api_push[platforms0-freezer][climate.refrigerator-state] +# name: test_climate_states_api_push[freezer-platforms0][climate.refrigerator-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 4, @@ -255,3 +255,195 @@ 'state': 'cool', }) # --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_freezer', + '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': 'Freezer', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'freezer', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat2-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -18, + 'friendly_name': 'Fridge freezer Freezer', + 'hvac_modes': list([ + , + ]), + 'max_temp': -14, + 'min_temp': -28, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -18, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_refrigerator', + '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': 'Refrigerator', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'refrigerator', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_refrigerator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4, + 'friendly_name': 'Fridge freezer Refrigerator', + 'hvac_modes': list([ + , + ]), + 'max_temp': 9, + 'min_temp': 1, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_refrigerator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fridge_freezer_zone_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': 'Zone 3', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'zone_3', + 'unique_id': 'DummyAppliance_Fridge_Freezer-thermostat3-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_states_mulizone[fridge_freezer-fridge_freezer.json-platforms0][climate.fridge_freezer_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -28, + 'friendly_name': 'Fridge freezer Zone 3', + 'hvac_modes': list([ + , + ]), + 'max_temp': -15, + 'min_temp': -30, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': -25, + }), + 'context': , + 'entity_id': 'climate.fridge_freezer_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/miele/test_climate.py b/tests/components/miele/test_climate.py index c4966430a9d..392a6712707 100644 --- a/tests/components/miele/test_climate.py +++ b/tests/components/miele/test_climate.py @@ -15,21 +15,13 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform TEST_PLATFORM = CLIMATE_DOMAIN -pytestmark = [ - pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]), - pytest.mark.parametrize( - "load_action_file", - ["action_freezer.json"], - ids=[ - "freezer", - ], - ), -] +pytestmark = pytest.mark.parametrize("platforms", [(TEST_PLATFORM,)]) ENTITY_ID = "climate.freezer" SERVICE_SET_TEMPERATURE = "set_temperature" +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_climate_states( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -42,7 +34,24 @@ async def test_climate_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize( + "load_action_file", ["action_fridge_freezer.json"], ids=["fridge_freezer"] +) +async def test_climate_states_mulizone( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test climate entity state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_climate_states_api_push( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -56,6 +65,7 @@ async def test_climate_states_api_push( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_set_target( hass: HomeAssistant, mock_miele_client: MagicMock, @@ -74,6 +84,7 @@ async def test_set_target( ) +@pytest.mark.parametrize("load_action_file", ["action_freezer.json"], ids=["freezer"]) async def test_api_failure( hass: HomeAssistant, mock_miele_client: MagicMock, From fea5c63bbae4e8ddea72032829f3b678a9163ca2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 3 Aug 2025 11:23:01 +0200 Subject: [PATCH 0853/1118] Fix Z-Wave handling of driver ready event (#149879) --- homeassistant/components/zwave_js/__init__.py | 12 +- homeassistant/components/zwave_js/api.py | 39 +-- .../components/zwave_js/config_flow.py | 26 +- homeassistant/components/zwave_js/const.py | 4 - homeassistant/components/zwave_js/helpers.py | 55 +++- tests/components/zwave_js/test_api.py | 284 ++++++++---------- tests/components/zwave_js/test_config_flow.py | 8 +- tests/components/zwave_js/test_init.py | 35 +++ 8 files changed, 259 insertions(+), 204 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 52a5a1b7388..923cd776f92 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -105,7 +105,6 @@ from .const import ( CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, - DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, EVENT_VALUE_UPDATED, LIB_LOGGER, @@ -136,6 +135,7 @@ from .models import ZwaveJSConfigEntry, ZwaveJSData from .services import async_setup_services CONNECT_TIMEOUT = 10 +DRIVER_READY_TIMEOUT = 60 CONFIG_SCHEMA = vol.Schema( { @@ -368,6 +368,16 @@ class DriverEvents: ) ) + # listen for driver ready event to reload the config entry + self.config_entry.async_on_unload( + driver.on( + "driver ready", + lambda _: self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ), + ) + ) + # listen for new nodes being added to the mesh self.config_entry.async_on_unload( controller.on( diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 0f75d8b4673..b392b1c95cd 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress import dataclasses @@ -87,7 +86,6 @@ from .const import ( CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, DOMAIN, - DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, LOGGER, USER_AGENT, @@ -98,6 +96,7 @@ from .helpers import ( async_get_node_from_device_id, async_get_provisioning_entry_from_device_id, async_get_version_info, + async_wait_for_driver_ready_event, get_device_id, ) @@ -2854,26 +2853,18 @@ async def websocket_hard_reset_controller( connection.send_result(msg[ID], device.id) async_cleanup() - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - msg[DATA_UNSUBSCRIBE] = unsubs = [ async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added ), - driver.once("driver ready", set_driver_ready), ] + wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver) + await driver.async_hard_reset() with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - + await wait_for_driver_ready() # When resetting the controller, the controller home id is also changed. # The controller state in the client is stale after resetting the controller, # so get the new home id with a new client using the helper function. @@ -2886,14 +2877,14 @@ async def websocket_hard_reset_controller( # The stale unique id needs to be handled by a repair flow, # after the config entry has been reloaded. LOGGER.error( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller reset" ) else: hass.config_entries.async_update_entry( entry, unique_id=str(version_info.home_id) ) - await hass.config_entries.async_reload(entry.entry_id) + hass.config_entries.async_schedule_reload(entry.entry_id) @websocket_api.websocket_command( @@ -3100,27 +3091,19 @@ async def websocket_restore_nvm( ) ) - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - # Set up subscription for progress events connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("nvm convert progress", forward_progress), controller.on("nvm restore progress", forward_progress), - driver.once("driver ready", set_driver_ready), ] + wait_for_driver_ready = async_wait_for_driver_ready_event(entry, driver) + await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - + await wait_for_driver_ready() # When restoring the NVM to the controller, the controller home id is also changed. # The controller state in the client is stale after restoring the NVM, # so get the new home id with a new client using the helper function. @@ -3133,14 +3116,13 @@ async def websocket_restore_nvm( # The stale unique id needs to be handled by a repair flow, # after the config entry has been reloaded. LOGGER.error( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller NVM restore" ) else: hass.config_entries.async_update_entry( entry, unique_id=str(version_info.home_id) ) - await hass.config_entries.async_reload(entry.entry_id) connection.send_message( @@ -3152,3 +3134,4 @@ async def websocket_restore_nvm( ) ) connection.send_result(msg[ID]) + async_cleanup() diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index d98dcf3dac8..308e6c9cc1a 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -62,9 +62,12 @@ from .const import ( CONF_USB_PATH, CONF_USE_ADDON, DOMAIN, - DRIVER_READY_TIMEOUT, ) -from .helpers import CannotConnect, async_get_version_info +from .helpers import ( + CannotConnect, + async_get_version_info, + async_wait_for_driver_ready_event, +) from .models import ZwaveJSConfigEntry _LOGGER = logging.getLogger(__name__) @@ -1396,19 +1399,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): event["bytesWritten"] / event["total"] * 0.5 + 0.5 ) - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - driver = self._get_driver() controller = driver.controller - wait_driver_ready = asyncio.Event() unsubs = [ controller.on("nvm convert progress", forward_progress), controller.on("nvm restore progress", forward_progress), - driver.once("driver ready", set_driver_ready), ] + + wait_for_driver_ready = async_wait_for_driver_ready_event(config_entry, driver) + try: await controller.async_restore_nvm( self.backup_data, {"preserveRoutes": False} @@ -1417,8 +1416,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): raise AbortFlow(f"Failed to restore network: {err}") from err else: with suppress(TimeoutError): - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() + await wait_for_driver_ready() try: version_info = await async_get_version_info( self.hass, config_entry.data[CONF_URL] @@ -1435,10 +1433,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( config_entry, unique_id=str(version_info.home_id) ) - await self.hass.config_entries.async_reload(config_entry.entry_id) - # Reload the config entry two times to clean up - # the stale device entry. + # The config entry will be also be reloaded when the driver is ready, + # by the listener in the package module, + # and two reloads are needed to clean up the stale controller device entry. # Since both the old and the new controller have the same node id, # but different hardware identifiers, the integration # will create a new device for the new controller, on the first reload, diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 6dc76ebd05d..0ccf51539d6 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -201,7 +201,3 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, } - -# Other constants - -DRIVER_READY_TIMEOUT = 60 diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5694be5482b..17f4909662c 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import astuple, dataclass import logging from typing import Any, cast @@ -56,6 +56,7 @@ from .const import ( ) from .models import ZwaveJSConfigEntry +DRIVER_READY_EVENT_TIMEOUT = 60 SERVER_VERSION_TIMEOUT = 10 @@ -588,5 +589,57 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio return version_info +@callback +def async_wait_for_driver_ready_event( + config_entry: ZwaveJSConfigEntry, + driver: Driver, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Wait for the driver ready event and the config entry reload. + + When the driver ready event is received + the config entry will be reloaded by the integration. + This function helps wait for that to happen + before proceeding with further actions. + + If the config entry is reloaded for another reason, + this function will not wait for it to be reloaded again. + + Raises TimeoutError if the driver ready event and reload + is not received within the specified timeout. + """ + driver_ready_event_received = asyncio.Event() + config_entry_reloaded = asyncio.Event() + unsubscribers: list[Callable[[], None]] = [] + + @callback + def driver_ready_received(event: dict) -> None: + """Receive the driver ready event.""" + driver_ready_event_received.set() + + unsubscribers.append(driver.once("driver ready", driver_ready_received)) + + @callback + def on_config_entry_state_change() -> None: + """Check config entry was loaded after driver ready event.""" + if config_entry.state is ConfigEntryState.LOADED: + config_entry_reloaded.set() + + unsubscribers.append( + config_entry.async_on_state_change(on_config_entry_state_change) + ) + + async def wait_for_events() -> None: + try: + async with asyncio.timeout(DRIVER_READY_EVENT_TIMEOUT): + await asyncio.gather( + driver_ready_event_received.wait(), config_entry_reloaded.wait() + ) + finally: + for unsubscribe in unsubscribers: + unsubscribe() + + return wait_for_events + + class CannotConnect(HomeAssistantError): """Indicate connection error.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 6359f4bf5e7..0b83d08072c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS Websocket API.""" +import asyncio from copy import deepcopy from http import HTTPStatus from io import BytesIO @@ -5109,17 +5110,12 @@ async def test_hard_reset_controller( ws_client = await hass_ws_client(hass) assert entry.unique_id == "3245146787" - async def async_send_command_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" + async def mock_driver_hard_reset() -> None: client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) - return {} - client.async_send_command.side_effect = async_send_command_driver_ready + client.driver.async_hard_reset = AsyncMock(side_effect=mock_driver_hard_reset) await ws_client.send_json_auto_id( { @@ -5128,6 +5124,7 @@ async def test_hard_reset_controller( } ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5135,16 +5132,10 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] - - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) + assert client.driver.async_hard_reset.call_count == 1 assert entry.unique_id == "1234" - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() # Test client connect error when getting the server version. @@ -5158,6 +5149,7 @@ async def test_hard_reset_controller( ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5165,33 +5157,24 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] - - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) + assert client.driver.async_hard_reset.call_count == 1 assert ( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller reset" ) in caplog.text - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() + get_server_version.side_effect = None # Test sending command with driver not ready and timeout. - async def async_send_command_no_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" - return {} + async def mock_driver_hard_reset_no_driver_ready() -> None: + pass - client.async_send_command.side_effect = async_send_command_no_driver_ready + client.driver.async_hard_reset.side_effect = mock_driver_hard_reset_no_driver_ready with patch( - "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT", new=0, ): await ws_client.send_json_auto_id( @@ -5201,6 +5184,7 @@ async def test_hard_reset_controller( } ) msg = await ws_client.receive_json() + await hass.async_block_till_done() device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} @@ -5208,32 +5192,29 @@ async def test_hard_reset_controller( assert device is not None assert msg["result"] == device.id assert msg["success"] + assert client.driver.async_hard_reset.call_count == 1 - assert client.async_send_command.call_count == 3 - # The first call is the relevant hard reset command. - # 25 is the require_schema parameter. - assert client.async_send_command.call_args_list[0] == call( - {"command": "driver.hard_reset"}, 25 - ) - - client.async_send_command.reset_mock() + client.driver.async_hard_reset.reset_mock() # Test FailedZWaveCommand is caught - with patch( - "zwave_js_server.model.driver.Driver.async_hard_reset", - side_effect=FailedZWaveCommand("failed_command", 1, "error message"), - ): - await ws_client.send_json_auto_id( - { - TYPE: "zwave_js/hard_reset_controller", - ENTRY_ID: entry.entry_id, - } - ) - msg = await ws_client.receive_json() + client.driver.async_hard_reset.side_effect = FailedZWaveCommand( + "failed_command", 1, "error message" + ) - assert not msg["success"] - assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + assert client.driver.async_hard_reset.call_count == 1 + + client.driver.async_hard_reset.side_effect = None # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -5578,17 +5559,24 @@ async def test_restore_nvm( # Set up mocks for the controller events controller = client.driver.controller - async def async_send_command_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" + async def mock_restore_nvm_base64( + self, base64_data: str, options: dict[str, bool] | None = None + ) -> None: + controller.emit( + "nvm convert progress", + {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, + ) + await asyncio.sleep(0) + controller.emit( + "nvm restore progress", + {"event": "nvm restore progress", "bytesWritten": 150, "total": 200}, + ) + controller.data["homeId"] = 3245146787 client.driver.emit( "driver ready", {"event": "driver ready", "source": "driver"} ) - return {} - client.async_send_command.side_effect = async_send_command_driver_ready + controller.async_restore_nvm_base64 = AsyncMock(side_effect=mock_restore_nvm_base64) # Send the subscription request await ws_client.send_json_auto_id( @@ -5599,7 +5587,19 @@ async def test_restore_nvm( } ) - # Verify the finished event first + # Verify the convert progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm convert progress" + assert msg["event"]["bytesRead"] == 100 + assert msg["event"]["total"] == 200 + + # Verify the restore progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 150 + assert msg["event"]["total"] == 200 + + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" assert msg["event"]["event"] == "finished" @@ -5609,53 +5609,18 @@ async def test_restore_nvm( assert msg["type"] == "result" assert msg["success"] is True - # Simulate progress events - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 25, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 25 - assert msg["event"]["total"] == 100 - - event = Event( - "nvm restore progress", - { - "source": "controller", - "event": "nvm restore progress", - "bytesWritten": 50, - "total": 100, - }, - ) - controller.receive_event(event) - msg = await ws_client.receive_json() - assert msg["event"]["event"] == "nvm restore progress" - assert msg["event"]["bytesWritten"] == 50 - assert msg["event"]["total"] == 100 - await hass.async_block_till_done() # Verify the restore was called # The first call is the relevant one for nvm restore. - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - "migrateOptions": {"preserveRoutes": False}, - }, - require_schema=42, + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) assert entry.unique_id == "1234" - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() # Test client connect error when getting the server version. @@ -5670,7 +5635,19 @@ async def test_restore_nvm( } ) - # Verify the finished event first + # Verify the convert progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm convert progress" + assert msg["event"]["bytesRead"] == 100 + assert msg["event"]["total"] == 200 + + # Verify the restore progress event + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 150 + assert msg["event"]["total"] == 200 + + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" assert msg["event"]["event"] == "finished" @@ -5680,47 +5657,46 @@ async def test_restore_nvm( assert msg["type"] == "result" assert msg["success"] is True - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - "migrateOptions": {"preserveRoutes": False}, - }, - require_schema=42, + await hass.async_block_till_done() + + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) assert ( - "Failed to get server version, cannot update config entry" + "Failed to get server version, cannot update config entry " "unique id with new home id, after controller NVM restore" ) in caplog.text - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() + get_server_version.side_effect = None - # Test sending command with driver not ready and timeout. + # Test sending command without driver ready event causing timeout. - async def async_send_command_no_driver_ready( - message: dict[str, Any], - require_schema: int | None = None, - ) -> dict: - """Send a command and get a response.""" - return {} + async def mock_restore_nvm_without_driver_ready( + data: bytes, options: dict[str, bool] | None = None + ): + controller.data["homeId"] = 3245146787 - client.async_send_command.side_effect = async_send_command_no_driver_ready + controller.async_restore_nvm_base64.side_effect = ( + mock_restore_nvm_without_driver_ready + ) with patch( - "homeassistant.components.zwave_js.api.DRIVER_READY_TIMEOUT", + "homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT", new=0, ): # Send the subscription request await ws_client.send_json_auto_id( { "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, + "entry_id": entry.entry_id, "data": "dGVzdA==", # base64 encoded "test" } ) - # Verify the finished event first + # Verify the finished event msg = await ws_client.receive_json() assert msg["type"] == "event" @@ -5734,37 +5710,41 @@ async def test_restore_nvm( await hass.async_block_till_done() # Verify the restore was called - # The first call is the relevant one for nvm restore. - assert client.async_send_command.call_count == 3 - assert client.async_send_command.call_args_list[0] == call( - { - "command": "controller.restore_nvm", - "nvmData": "dGVzdA==", - "migrateOptions": {"preserveRoutes": False}, - }, - require_schema=42, + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, ) - client.async_send_command.reset_mock() + controller.async_restore_nvm_base64.reset_mock() # Test restore failure - with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_restore_nvm_base64", - side_effect=FailedZWaveCommand("failed_command", 1, "error message"), - ): - # Send the subscription request - await ws_client.send_json_auto_id( - { - "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, - "data": "dGVzdA==", # base64 encoded "test" - } - ) + controller.async_restore_nvm_base64.side_effect = FailedZWaveCommand( + "failed_command", 1, "error message" + ) - # Verify error response - msg = await ws_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "zwave_error" + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": entry.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + + await hass.async_block_till_done() + + # Verify the restore was called + assert controller.async_restore_nvm_base64.call_count == 1 + assert controller.async_restore_nvm_base64.call_args == call( + "dGVzdA==", + {"preserveRoutes": False}, + ) # Test entry_id not found await ws_client.send_json_auto_id( @@ -5779,13 +5759,13 @@ async def test_restore_nvm( assert msg["error"]["code"] == "not_found" # Test config entry not loaded - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json_auto_id( { "type": "zwave_js/restore_nvm", - "entry_id": integration.entry_id, + "entry_id": entry.entry_id, "data": "dGVzdA==", # base64 encoded "test" } ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 15ec6959caf..52b840fb690 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1101,7 +1101,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert restart_addon.call_args == call("core_zwave_js") with patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"), new=0, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1111,7 +1111,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3897,7 +3897,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert restart_addon.call_args == call("core_zwave_js") with patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), + ("homeassistant.components.zwave_js.helpers.DRIVER_READY_EVENT_TIMEOUT"), new=0, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -3907,7 +3907,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 4 + assert client.connect.call_count == 3 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4decb061ad0..3c39868ff93 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -2262,3 +2262,38 @@ async def test_entity_available_when_node_dead( state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) assert state assert state.state != STATE_UNAVAILABLE + + +async def test_driver_ready_event( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test receiving a driver ready event.""" + config_entry = integration + assert config_entry.state is ConfigEntryState.LOADED + + config_entry_state_changes: list[ConfigEntryState] = [] + + def on_config_entry_state_change() -> None: + """Collect config entry state changes.""" + config_entry_state_changes.append(config_entry.state) + + config_entry.async_on_state_change(on_config_entry_state_change) + + driver_ready = Event( + type="driver ready", + data={ + "source": "driver", + "event": "driver ready", + }, + ) + + client.driver.receive_event(driver_ready) + await hass.async_block_till_done() + + assert len(config_entry_state_changes) == 4 + assert config_entry_state_changes[0] == ConfigEntryState.UNLOAD_IN_PROGRESS + assert config_entry_state_changes[1] == ConfigEntryState.NOT_LOADED + assert config_entry_state_changes[2] == ConfigEntryState.SETUP_IN_PROGRESS + assert config_entry_state_changes[3] == ConfigEntryState.LOADED From 4318e29ce8732acdc881b2df9ac6f23074d0bade Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 3 Aug 2025 13:18:13 +0100 Subject: [PATCH 0854/1118] Bump aiomealie to 0.10.1 (#149890) --- homeassistant/components/mealie/manifest.json | 2 +- homeassistant/components/mealie/todo.py | 5 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mealie/fixtures/get_recipe.json | 4 ---- .../mealie/fixtures/get_shopping_items.json | 2 -- .../mealie/snapshots/test_diagnostics.ambr | 12 ++++++------ tests/components/mealie/snapshots/test_services.ambr | 8 ++++---- tests/components/mealie/test_todo.py | 2 -- 9 files changed, 16 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 804011b3d9a..a744b9e6ced 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["aiomealie==0.10.0"] + "requirements": ["aiomealie==0.10.1"] } diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index d42c9033922..e31af281783 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -174,7 +174,8 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): if list_item.display.strip() != stripped_item_summary: update_shopping_item.note = stripped_item_summary update_shopping_item.position = position - update_shopping_item.is_food = False + if update_shopping_item.is_food is not None: + update_shopping_item.is_food = False update_shopping_item.food_id = None update_shopping_item.quantity = 0.0 update_shopping_item.checked = item.status == TodoItemStatus.COMPLETED @@ -249,7 +250,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): mutate_shopping_item.note = item.note mutate_shopping_item.checked = item.checked - if item.is_food: + if item.is_food or item.food_id: mutate_shopping_item.food_id = item.food_id mutate_shopping_item.unit_id = item.unit_id diff --git a/requirements_all.txt b/requirements_all.txt index eede7860c51..6ee3fcaa79f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -310,7 +310,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.10.0 +aiomealie==0.10.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ef659032dd..320a58eea5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,7 +292,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.10.0 +aiomealie==0.10.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/fixtures/get_recipe.json b/tests/components/mealie/fixtures/get_recipe.json index a5ccd1876e5..7e42986ebdc 100644 --- a/tests/components/mealie/fixtures/get_recipe.json +++ b/tests/components/mealie/fixtures/get_recipe.json @@ -63,8 +63,6 @@ "unit": null, "food": null, "note": "130g dark couverture chocolate (min. 55% cocoa content)", - "isFood": true, - "disableAmount": false, "display": "1 130g dark couverture chocolate (min. 55% cocoa content)", "title": null, "originalText": null, @@ -87,8 +85,6 @@ "unit": null, "food": null, "note": "150g softened butter", - "isFood": true, - "disableAmount": false, "display": "1 150g softened butter", "title": null, "originalText": null, diff --git a/tests/components/mealie/fixtures/get_shopping_items.json b/tests/components/mealie/fixtures/get_shopping_items.json index 1016440816b..81db48f2e1a 100644 --- a/tests/components/mealie/fixtures/get_shopping_items.json +++ b/tests/components/mealie/fixtures/get_shopping_items.json @@ -9,8 +9,6 @@ "unit": null, "food": null, "note": "Apples", - "isFood": false, - "disableAmount": true, "display": "2 Apples", "shoppingListId": "9ce096fe-ded2-4077-877d-78ba450ab13e", "checked": false, diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index a694c72fcf6..c4d649fcec6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -383,10 +383,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', @@ -433,10 +433,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', @@ -483,10 +483,10 @@ 'items': list([ dict({ 'checked': False, - 'disable_amount': True, + 'disable_amount': None, 'display': '2 Apples', 'food_id': None, - 'is_food': False, + 'is_food': None, 'item_id': 'f45430f7-3edf-45a9-a50f-73bb375090be', 'label_id': None, 'list_id': '9ce096fe-ded2-4077-877d-78ba450ab13e', diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 257d685d8dc..a1cb758098e 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1247,7 +1247,7 @@ 'image': 'SuPW', 'ingredients': list([ dict({ - 'is_food': True, + 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', @@ -1261,7 +1261,7 @@ 'unit': None, }), dict({ - 'is_food': True, + 'is_food': None, 'note': '150g softened butter', 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', @@ -1763,7 +1763,7 @@ 'image': 'SuPW', 'ingredients': list([ dict({ - 'is_food': True, + 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', @@ -1777,7 +1777,7 @@ 'unit': None, }), dict({ - 'is_food': True, + 'is_food': None, 'note': '150g softened butter', 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', diff --git a/tests/components/mealie/test_todo.py b/tests/components/mealie/test_todo.py index d156ef3a0f1..0f001cacacd 100644 --- a/tests/components/mealie/test_todo.py +++ b/tests/components/mealie/test_todo.py @@ -221,8 +221,6 @@ async def test_moving_todo_item( display=None, checked=False, position=1, - is_food=False, - disable_amount=None, quantity=2.0, label_id=None, food_id=None, From 627785edc1597df3fabc9a81c2436a1dc374ed0e Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 3 Aug 2025 20:05:23 +0200 Subject: [PATCH 0855/1118] Fix options for error sensor in Husqvarna Automower (#149901) --- .../components/husqvarna_automower/sensor.py | 7 +- .../snapshots/test_sensor.ambr | 64 +++++++++---------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 7f2921f17fa..c5af18c6387 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -71,10 +71,10 @@ ERROR_KEYS = [ "cutting_drive_motor_2_defect", "cutting_drive_motor_3_defect", "cutting_height_blocked", + "cutting_height_problem", "cutting_height_problem_curr", "cutting_height_problem_dir", "cutting_height_problem_drive", - "cutting_height_problem", "cutting_motor_problem", "cutting_stopped_slope_too_steep", "cutting_system_blocked", @@ -117,7 +117,6 @@ ERROR_KEYS = [ "no_accurate_position_from_satellites", "no_confirmed_position", "no_drive", - "no_error", "no_loop_signal", "no_power_in_charging_station", "no_response_from_charger", @@ -169,8 +168,8 @@ ERROR_KEYS = [ ] -ERROR_KEY_LIST = list( - dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) +ERROR_KEY_LIST = sorted( + set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"} ) INACTIVE_REASONS: list = [ diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 3aa3504cc26..6628113d8c3 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -205,10 +205,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -219,6 +219,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -255,6 +258,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -268,6 +272,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -283,6 +288,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -300,13 +307,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'config_entry_id': , @@ -372,10 +372,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -386,6 +386,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -422,6 +425,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -435,6 +439,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -450,6 +455,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -467,13 +474,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'context': , @@ -1568,10 +1568,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -1582,6 +1582,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -1618,6 +1621,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -1631,6 +1635,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -1646,6 +1651,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -1663,13 +1670,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'config_entry_id': , @@ -1735,10 +1735,10 @@ 'cutting_drive_motor_2_defect', 'cutting_drive_motor_3_defect', 'cutting_height_blocked', + 'cutting_height_problem', 'cutting_height_problem_curr', 'cutting_height_problem_dir', 'cutting_height_problem_drive', - 'cutting_height_problem', 'cutting_motor_problem', 'cutting_stopped_slope_too_steep', 'cutting_system_blocked', @@ -1749,6 +1749,9 @@ 'docking_sensor_defect', 'electronic_problem', 'empty_battery', + 'error', + 'error_at_power_up', + 'fatal_error', 'folding_cutting_deck_sensor_defect', 'folding_sensor_activated', 'geofence_problem', @@ -1785,6 +1788,7 @@ 'no_loop_signal', 'no_power_in_charging_station', 'no_response_from_charger', + 'off', 'outside_working_area', 'poor_signal_quality', 'reference_station_communication_problem', @@ -1798,6 +1802,7 @@ 'slope_too_steep', 'sms_could_not_be_sent', 'stop_button_problem', + 'stopped', 'stuck_in_charging_station', 'switch_cord_problem', 'temporary_battery_problem', @@ -1813,6 +1818,8 @@ 'unexpected_cutting_height_adj', 'unexpected_error', 'upside_down', + 'wait_power_up', + 'wait_updating', 'weak_gps_signal', 'wheel_drive_problem_left', 'wheel_drive_problem_rear_left', @@ -1830,13 +1837,6 @@ 'wrong_loop_signal', 'wrong_pin_code', 'zone_generator_problem', - 'error_at_power_up', - 'error', - 'fatal_error', - 'off', - 'stopped', - 'wait_power_up', - 'wait_updating', ]), }), 'context': , From b9e16d54c4416c4c2319a69b2280608e7985eec2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 3 Aug 2025 20:06:14 +0200 Subject: [PATCH 0856/1118] Add jitter sensor to Ping integration (#149899) --- homeassistant/components/ping/helpers.py | 1 + homeassistant/components/ping/sensor.py | 11 ++++ homeassistant/components/ping/strings.json | 3 + .../ping/snapshots/test_sensor.ambr | 55 +++++++++++++++++++ tests/components/ping/test_sensor.py | 1 + 5 files changed, 71 insertions(+) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 996faa99c5b..8000cbcddde 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -79,6 +79,7 @@ class PingDataICMPLib(PingData): "min": data.min_rtt, "max": data.max_rtt, "avg": data.avg_rtt, + "jitter": data.jitter, } diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index 82d88064e02..b3866c9f0e7 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -71,6 +71,17 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = ( value_fn=lambda result: result.data.get("min"), has_fn=lambda result: "min" in result.data, ), + PingSensorEntityDescription( + key="jitter", + translation_key="jitter", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("jitter"), + has_fn=lambda result: "jitter" in result.data, + ), ) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index c301a1b277d..4dc2e8ec7fc 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -12,6 +12,9 @@ }, "round_trip_time_min": { "name": "Round-trip time minimum" + }, + "jitter": { + "name": "Jitter" } } }, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index f09bfe61065..8cb8642f13a 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_setup_and_update[jitter] + 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.10_10_10_10_jitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Jitter', + 'platform': 'ping', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jitter', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_and_update[jitter].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '10.10.10.10 Jitter', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_10_10_10_jitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.5', + }) +# --- # name: test_setup_and_update[round_trip_time_average] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ping/test_sensor.py b/tests/components/ping/test_sensor.py index bdc8b7d28e4..95a31aa5c08 100644 --- a/tests/components/ping/test_sensor.py +++ b/tests/components/ping/test_sensor.py @@ -16,6 +16,7 @@ from homeassistant.helpers import entity_registry as er "round_trip_time_maximum", "round_trip_time_mean_deviation", # should be None in the snapshot "round_trip_time_minimum", + "jitter", ], ) async def test_setup_and_update( From e0190afd3c9b5fbecb02bc13c9da9686ffa93ac7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 3 Aug 2025 20:07:01 +0200 Subject: [PATCH 0857/1118] Bump `imgw_pib` to version 1.5.2 (#149892) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 62a4f41ba1f..e65ccf35fb5 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.1"] + "requirements": ["imgw_pib==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ee3fcaa79f..8a8c0c99615 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1240,7 +1240,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.1 +imgw_pib==1.5.2 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 320a58eea5e..f8e8d8865dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.1 +imgw_pib==1.5.2 # homeassistant.components.incomfort incomfort-client==0.6.9 From 084e06ec7d9dc0e79d222cc443aff05d642d8975 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Aug 2025 21:46:40 +0200 Subject: [PATCH 0858/1118] Bump python-open-router to 0.3.1 (#149873) --- homeassistant/components/open_router/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index fab62e7971c..8f989e63189 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.93.3", "python-open-router==0.3.0"] + "requirements": ["openai==1.93.3", "python-open-router==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a8c0c99615..092253636ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2481,7 +2481,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.3.0 +python-open-router==0.3.1 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8e8d8865dd..998dc9e6e6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,7 +2054,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.3.0 +python-open-router==0.3.1 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 From b3f830773adaa49330a82f41e5ff0d6510499ee6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 15:02:30 -1000 Subject: [PATCH 0859/1118] Bump yalexs-ble to 3.1.2 (#149917) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 2368c848eea..e7af7d84942 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 5b45628ee64..aa68009ac72 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7a02afbc5d7..b1fad926f1d 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==3.1.0"] + "requirements": ["yalexs-ble==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 092253636ad..fd606834c7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3163,7 +3163,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.1.0 +yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 998dc9e6e6a..fee7ee5db48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2613,7 +2613,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==3.1.0 +yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale From 179a56628d8409390172697893a6295518beb70e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:42:11 -1000 Subject: [PATCH 0860/1118] Bump dbus-fast to 2.44.3 (#149921) --- homeassistant/components/bluetooth/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/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 3b1e6e70ff6..cd6aae91259 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", - "dbus-fast==2.44.2", + "dbus-fast==2.44.3", "habluetooth==4.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac91084c4f1..c03bebc32e1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==45.0.3 -dbus-fast==2.44.2 +dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index fd606834c7e..23379744ee8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,7 +765,7 @@ datadog==0.52.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.44.2 +dbus-fast==2.44.3 # homeassistant.components.debugpy debugpy==1.8.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fee7ee5db48..8654e0cfb49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -668,7 +668,7 @@ datadog==0.52.0 datapoint==0.12.1 # homeassistant.components.bluetooth -dbus-fast==2.44.2 +dbus-fast==2.44.3 # homeassistant.components.debugpy debugpy==1.8.14 From 6a8d752e56f4837b8e5086ed4b4033372c1e3bb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Aug 2025 16:42:38 -1000 Subject: [PATCH 0861/1118] Bump aiodiscover to 2.7.1 (#149920) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index ea2a4f4f820..599e5ecae5b 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "aiodhcpwatcher==1.2.0", - "aiodiscover==2.7.0", + "aiodiscover==2.7.1", "cached-ipaddress==0.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c03bebc32e1..80354c706d6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit aiodhcpwatcher==1.2.0 -aiodiscover==2.7.0 +aiodiscover==2.7.1 aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 23379744ee8..d0df5b416d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aiocomelit==0.12.3 aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp -aiodiscover==2.7.0 +aiodiscover==2.7.1 # homeassistant.components.dnsip aiodns==3.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8654e0cfb49..aa48c183a3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -211,7 +211,7 @@ aiocomelit==0.12.3 aiodhcpwatcher==1.2.0 # homeassistant.components.dhcp -aiodiscover==2.7.0 +aiodiscover==2.7.1 # homeassistant.components.dnsip aiodns==3.5.0 From 5467db065bf08596815eede571a7e6cd28b8f451 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 07:59:47 +0200 Subject: [PATCH 0862/1118] Make Tuya complex type handling explicit (#149677) --- homeassistant/components/tuya/models.py | 16 ++++++++++- homeassistant/components/tuya/sensor.py | 38 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index b4afca83a85..43e4c04c518 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -99,8 +99,22 @@ class EnumTypeData: return cls(dpcode, **parsed) +class ComplexTypeData: + """Complex Type Data (for JSON/RAW parsing).""" + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ComplexTypeData object.""" + raise NotImplementedError("from_json is not implemented for this type") + + @classmethod + def from_raw(cls, data: str) -> Self: + """Decode base64 string and return a ComplexTypeData object.""" + raise NotImplementedError("from_raw is not implemented for this type") + + @dataclass -class ElectricityTypeData: +class ElectricityTypeData(ComplexTypeData): """Electricity Type Data.""" electriccurrent: str | None = None diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 6e8da29ef53..da7a57b1be2 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -40,13 +40,14 @@ from .const import ( UnitOfMeasurement, ) from .entity import TuyaEntity -from .models import ElectricityTypeData, EnumTypeData, IntegerTypeData +from .models import ComplexTypeData, ElectricityTypeData, EnumTypeData, IntegerTypeData @dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" + complex_type: type[ComplexTypeData] | None = None subkey: str | None = None @@ -368,6 +369,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -376,6 +378,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -384,6 +387,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), TuyaSensorEntityDescription( @@ -392,6 +396,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -400,6 +405,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -408,6 +414,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), TuyaSensorEntityDescription( @@ -416,6 +423,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -424,6 +432,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -432,6 +441,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1247,6 +1257,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="total_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -1255,6 +1266,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1263,6 +1275,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -1271,6 +1284,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1279,6 +1293,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1287,6 +1302,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -1295,6 +1311,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), TuyaSensorEntityDescription( @@ -1303,6 +1320,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + complex_type=ElectricityTypeData, subkey="electriccurrent", ), TuyaSensorEntityDescription( @@ -1311,6 +1329,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, + complex_type=ElectricityTypeData, subkey="power", ), TuyaSensorEntityDescription( @@ -1319,6 +1338,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, + complex_type=ElectricityTypeData, subkey="voltage", ), ), @@ -1417,7 +1437,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): _status_range: DeviceStatusRange | None = None _type: DPType | None = None - _type_data: IntegerTypeData | EnumTypeData | None = None + _type_data: IntegerTypeData | EnumTypeData | ComplexTypeData | None = None _uom: UnitOfMeasurement | None = None def __init__( @@ -1516,15 +1536,21 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Get subkey value from Json string. if self._type is DPType.JSON: - if self.entity_description.subkey is None: + if ( + self.entity_description.complex_type is None + or self.entity_description.subkey is None + ): return None - values = ElectricityTypeData.from_json(value) + values = self.entity_description.complex_type.from_json(value) return getattr(values, self.entity_description.subkey) if self._type is DPType.RAW: - if self.entity_description.subkey is None: + if ( + self.entity_description.complex_type is None + or self.entity_description.subkey is None + ): return None - values = ElectricityTypeData.from_raw(value) + values = self.entity_description.complex_type.from_raw(value) return getattr(values, self.entity_description.subkey) # Valid string or enum value From 551dcaa1695dd43047d13d86abb2db72e87ed8e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:08:03 +0200 Subject: [PATCH 0863/1118] Rename Tuya fixture files (#149927) --- tests/components/tuya/__init__.py | 90 +-- ...tor_zigbee_cover.json => cl_zah67ekd.json} | 0 ...curtain_switch.json => clkg_nhyj64w2.json} | 0 ...ector.json => co2bj_yrr3eiyiacm31ski.json} | 0 ...midifier.json => cs_ka2wfrdoogpvgzfi.json} | 0 ...dry_plus.json => cs_vmxuxszzjwp5smli.json} | 0 ...purifier.json => cs_zibqa9dutqyaxym2.json} | 0 ...or_eliminator.json => cwjwq_agwu93lr.json} | 0 ...pf100.json => cwwsq_wfkzyy0evslzsmoi.json} | 0 ...ntain.json => cwysj_z3rpyvznfcch99aa.json} | 0 ...metering.json => cz_2jxesipczks0kdct.json} | 0 ...ght_bulb.json => dj_mki13ie507rlry4r.json} | 0 ..._eawcpt.json => dlq_0tnvg2xaisqdadcf.json} | 0 ...pn_wifi.json => dlq_kxdr6su0c55p7bbo.json} | 0 ...t_light.json => gyd_lgekqfxdabipm3tn.json} | 0 ...rt_valve.json => kg_gbm9ata1zrzaez4a.json} | 0 ...ower_fan.json => kj_yrzylxax1qspdgpp.json} | 0 ...ower_fan.json => ks_j9fa8ahzac8uvlfl.json} | 0 ...ditioner.json => kt_5wnlzekkstwcdsvm.json} | 0 ...rm_host.json => mal_gyitctrjj1kefxp2.json} | 0 ..._sensor.json => mcs_7jIGJAymiH8OsFFb.json} | 0 ...ntrol.json => qccdz_7bvgooyjhiua1yyq.json} | 0 ...station.json => qxj_fsea1lat3vuktbt6.json} | 0 ...l_probe.json => qxj_is2indt9nlth6esa.json} | 0 ...sensor.json => rqbj_4iqe2hsfyd86kwwc.json} | 0 ...oller.json => sfkzq_o6dagifntoafakst.json} | 0 ...q_4_443.json => tdq_cq1p0nt0a4rixnex.json} | 0 ..._air_conditioner.json => wk_aqoouq7x.json} | 0 ...ermostat.json => wk_fi6dne5tu4t1nm6j.json} | 0 ...idity.json => wsdcg_g2y6z3p3ja2qhyav.json} | 0 ...switch.json => wxkg_l8yaz4um5b3pwyvf.json} | 0 ...ported.json => ydkt_jevroj5aguwdbs2e.json} | 0 ..._meter.json => zndb_ze8faryrxr0glqnn.json} | 0 .../snapshots/test_alarm_control_panel.ambr | 4 +- .../tuya/snapshots/test_binary_sensor.ambr | 314 ++++---- .../tuya/snapshots/test_climate.ambr | 12 +- .../components/tuya/snapshots/test_cover.ambr | 8 +- .../tuya/snapshots/test_diagnostics.ambr | 4 +- .../components/tuya/snapshots/test_event.ambr | 8 +- tests/components/tuya/snapshots/test_fan.ambr | 116 +-- .../tuya/snapshots/test_humidifier.ambr | 224 +++--- .../components/tuya/snapshots/test_init.ambr | 2 +- .../components/tuya/snapshots/test_light.ambr | 16 +- .../tuya/snapshots/test_number.ambr | 24 +- .../tuya/snapshots/test_select.ambr | 146 ++-- .../tuya/snapshots/test_sensor.ambr | 708 +++++++++--------- .../components/tuya/snapshots/test_siren.ambr | 4 +- .../tuya/snapshots/test_switch.ambr | 198 ++--- tests/components/tuya/test_binary_sensor.py | 4 +- tests/components/tuya/test_climate.py | 8 +- tests/components/tuya/test_cover.py | 10 +- tests/components/tuya/test_diagnostics.py | 4 +- tests/components/tuya/test_humidifier.py | 12 +- tests/components/tuya/test_init.py | 2 +- tests/components/tuya/test_light.py | 4 +- tests/components/tuya/test_number.py | 4 +- tests/components/tuya/test_select.py | 4 +- 57 files changed, 965 insertions(+), 965 deletions(-) rename tests/components/tuya/fixtures/{cl_am43_corded_motor_zigbee_cover.json => cl_zah67ekd.json} (100%) rename tests/components/tuya/fixtures/{clkg_curtain_switch.json => clkg_nhyj64w2.json} (100%) rename tests/components/tuya/fixtures/{co2bj_air_detector.json => co2bj_yrr3eiyiacm31ski.json} (100%) rename tests/components/tuya/fixtures/{cs_emma_dehumidifier.json => cs_ka2wfrdoogpvgzfi.json} (100%) rename tests/components/tuya/fixtures/{cs_smart_dry_plus.json => cs_vmxuxszzjwp5smli.json} (100%) rename tests/components/tuya/fixtures/{cs_arete_two_12l_dehumidifier_air_purifier.json => cs_zibqa9dutqyaxym2.json} (100%) rename tests/components/tuya/fixtures/{cwjwq_smart_odor_eliminator.json => cwjwq_agwu93lr.json} (100%) rename tests/components/tuya/fixtures/{cwwsq_cleverio_pf100.json => cwwsq_wfkzyy0evslzsmoi.json} (100%) rename tests/components/tuya/fixtures/{cwysj_pixi_smart_drinking_fountain.json => cwysj_z3rpyvznfcch99aa.json} (100%) rename tests/components/tuya/fixtures/{cz_dual_channel_metering.json => cz_2jxesipczks0kdct.json} (100%) rename tests/components/tuya/fixtures/{dj_smart_light_bulb.json => dj_mki13ie507rlry4r.json} (100%) rename tests/components/tuya/fixtures/{dlq_earu_electric_eawcpt.json => dlq_0tnvg2xaisqdadcf.json} (100%) rename tests/components/tuya/fixtures/{dlq_metering_3pn_wifi.json => dlq_kxdr6su0c55p7bbo.json} (100%) rename tests/components/tuya/fixtures/{gyd_night_light.json => gyd_lgekqfxdabipm3tn.json} (100%) rename tests/components/tuya/fixtures/{kg_smart_valve.json => kg_gbm9ata1zrzaez4a.json} (100%) rename tests/components/tuya/fixtures/{kj_bladeless_tower_fan.json => kj_yrzylxax1qspdgpp.json} (100%) rename tests/components/tuya/fixtures/{ks_tower_fan.json => ks_j9fa8ahzac8uvlfl.json} (100%) rename tests/components/tuya/fixtures/{kt_serenelife_slpac905wuk_air_conditioner.json => kt_5wnlzekkstwcdsvm.json} (100%) rename tests/components/tuya/fixtures/{mal_alarm_host.json => mal_gyitctrjj1kefxp2.json} (100%) rename tests/components/tuya/fixtures/{mcs_door_sensor.json => mcs_7jIGJAymiH8OsFFb.json} (100%) rename tests/components/tuya/fixtures/{qccdz_ac_charging_control.json => qccdz_7bvgooyjhiua1yyq.json} (100%) rename tests/components/tuya/fixtures/{qxj_weather_station.json => qxj_fsea1lat3vuktbt6.json} (100%) rename tests/components/tuya/fixtures/{qxj_temp_humidity_external_probe.json => qxj_is2indt9nlth6esa.json} (100%) rename tests/components/tuya/fixtures/{rqbj_gas_sensor.json => rqbj_4iqe2hsfyd86kwwc.json} (100%) rename tests/components/tuya/fixtures/{sfkzq_valve_controller.json => sfkzq_o6dagifntoafakst.json} (100%) rename tests/components/tuya/fixtures/{tdq_4_443.json => tdq_cq1p0nt0a4rixnex.json} (100%) rename tests/components/tuya/fixtures/{wk_air_conditioner.json => wk_aqoouq7x.json} (100%) rename tests/components/tuya/fixtures/{wk_wifi_smart_gas_boiler_thermostat.json => wk_fi6dne5tu4t1nm6j.json} (100%) rename tests/components/tuya/fixtures/{wsdcg_temperature_humidity.json => wsdcg_g2y6z3p3ja2qhyav.json} (100%) rename tests/components/tuya/fixtures/{wxkg_wireless_switch.json => wxkg_l8yaz4um5b3pwyvf.json} (100%) rename tests/components/tuya/fixtures/{ydkt_dolceclima_unsupported.json => ydkt_jevroj5aguwdbs2e.json} (100%) rename tests/components/tuya/fixtures/{zndb_smart_meter.json => zndb_ze8faryrxr0glqnn.json} (100%) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index d793b87854a..040ee1fec2f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -14,17 +14,17 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { - "cl_am43_corded_motor_zigbee_cover": [ + "cl_zah67ekd": [ # https://github.com/home-assistant/core/issues/71242 Platform.COVER, Platform.SELECT, ], - "clkg_curtain_switch": [ + "clkg_nhyj64w2": [ # https://github.com/home-assistant/core/issues/136055 Platform.COVER, Platform.LIGHT, ], - "co2bj_air_detector": [ + "co2bj_yrr3eiyiacm31ski": [ # https://github.com/home-assistant/core/issues/133173 Platform.BINARY_SENSOR, Platform.NUMBER, @@ -32,15 +32,7 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SIREN, ], - "cs_arete_two_12l_dehumidifier_air_purifier": [ - Platform.BINARY_SENSOR, - Platform.FAN, - Platform.HUMIDIFIER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "cs_emma_dehumidifier": [ + "cs_ka2wfrdoogpvgzfi": [ # https://github.com/home-assistant/core/issues/119865 Platform.BINARY_SENSOR, Platform.FAN, @@ -49,102 +41,110 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], - "cs_smart_dry_plus": [ + "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 Platform.FAN, Platform.HUMIDIFIER, ], - "cwjwq_smart_odor_eliminator": [ + "cs_zibqa9dutqyaxym2": [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "cwjwq_agwu93lr": [ # https://github.com/orgs/home-assistant/discussions/79 Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ], - "cwwsq_cleverio_pf100": [ + "cwwsq_wfkzyy0evslzsmoi": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, Platform.SENSOR, ], - "cwysj_pixi_smart_drinking_fountain": [ + "cwysj_z3rpyvznfcch99aa": [ # https://github.com/home-assistant/core/pull/146599 Platform.SENSOR, Platform.SWITCH, ], - "cz_dual_channel_metering": [ + "cz_2jxesipczks0kdct": [ # https://github.com/home-assistant/core/issues/147149 Platform.SENSOR, Platform.SWITCH, ], - "dj_smart_light_bulb": [ + "dj_mki13ie507rlry4r": [ # https://github.com/home-assistant/core/pull/126242 Platform.LIGHT ], - "dlq_earu_electric_eawcpt": [ + "dlq_0tnvg2xaisqdadcf": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, Platform.SWITCH, ], - "dlq_metering_3pn_wifi": [ + "dlq_kxdr6su0c55p7bbo": [ # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], - "gyd_night_light": [ + "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, ], - "kg_smart_valve": [ + "kg_gbm9ata1zrzaez4a": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, ], - "kj_bladeless_tower_fan": [ + "kj_yrzylxax1qspdgpp": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, Platform.SELECT, Platform.SWITCH, ], - "ks_tower_fan": [ + "ks_j9fa8ahzac8uvlfl": [ # https://github.com/orgs/home-assistant/discussions/329 Platform.FAN, Platform.LIGHT, Platform.SWITCH, ], - "kt_serenelife_slpac905wuk_air_conditioner": [ + "kt_5wnlzekkstwcdsvm": [ # https://github.com/home-assistant/core/pull/148646 Platform.CLIMATE, ], - "mal_alarm_host": [ + "mal_gyitctrjj1kefxp2": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, Platform.NUMBER, Platform.SWITCH, ], - "mcs_door_sensor": [ + "mcs_7jIGJAymiH8OsFFb": [ # https://github.com/home-assistant/core/issues/108301 Platform.BINARY_SENSOR, Platform.SENSOR, ], - "qccdz_ac_charging_control": [ + "qccdz_7bvgooyjhiua1yyq": [ # https://github.com/home-assistant/core/issues/136207 Platform.SWITCH, ], - "qxj_temp_humidity_external_probe": [ - # https://github.com/home-assistant/core/issues/136472 - Platform.SENSOR, - ], - "qxj_weather_station": [ + "qxj_fsea1lat3vuktbt6": [ # https://github.com/orgs/home-assistant/discussions/318 Platform.SENSOR, ], - "rqbj_gas_sensor": [ + "qxj_is2indt9nlth6esa": [ + # https://github.com/home-assistant/core/issues/136472 + Platform.SENSOR, + ], + "rqbj_4iqe2hsfyd86kwwc": [ # https://github.com/orgs/home-assistant/discussions/100 Platform.BINARY_SENSOR, Platform.SENSOR, ], - "sfkzq_valve_controller": [ + "sfkzq_o6dagifntoafakst": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, ], - "tdq_4_443": [ + "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, Platform.SWITCH, @@ -155,32 +155,32 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], - "wk_air_conditioner": [ + "wk_aqoouq7x": [ # https://github.com/home-assistant/core/issues/146263 Platform.CLIMATE, Platform.SWITCH, ], - "ydkt_dolceclima_unsupported": [ - # https://github.com/orgs/home-assistant/discussions/288 - # unsupported device - no platforms - ], - "wk_wifi_smart_gas_boiler_thermostat": [ + "wk_fi6dne5tu4t1nm6j": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ], - "wsdcg_temperature_humidity": [ + "wsdcg_g2y6z3p3ja2qhyav": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, ], - "wxkg_wireless_switch": [ + "wxkg_l8yaz4um5b3pwyvf": [ # https://github.com/home-assistant/core/issues/93975 Platform.EVENT, Platform.SENSOR, ], - "zndb_smart_meter": [ + "ydkt_jevroj5aguwdbs2e": [ + # https://github.com/orgs/home-assistant/discussions/288 + # unsupported device - no platforms + ], + "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, ], diff --git a/tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/cl_zah67ekd.json similarity index 100% rename from tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json rename to tests/components/tuya/fixtures/cl_zah67ekd.json diff --git a/tests/components/tuya/fixtures/clkg_curtain_switch.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json similarity index 100% rename from tests/components/tuya/fixtures/clkg_curtain_switch.json rename to tests/components/tuya/fixtures/clkg_nhyj64w2.json diff --git a/tests/components/tuya/fixtures/co2bj_air_detector.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json similarity index 100% rename from tests/components/tuya/fixtures/co2bj_air_detector.json rename to tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json diff --git a/tests/components/tuya/fixtures/cs_emma_dehumidifier.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json similarity index 100% rename from tests/components/tuya/fixtures/cs_emma_dehumidifier.json rename to tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json diff --git a/tests/components/tuya/fixtures/cs_smart_dry_plus.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json similarity index 100% rename from tests/components/tuya/fixtures/cs_smart_dry_plus.json rename to tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json diff --git a/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json b/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json similarity index 100% rename from tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json rename to tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json diff --git a/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json similarity index 100% rename from tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json rename to tests/components/tuya/fixtures/cwjwq_agwu93lr.json diff --git a/tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json similarity index 100% rename from tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json rename to tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json diff --git a/tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json similarity index 100% rename from tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json rename to tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json diff --git a/tests/components/tuya/fixtures/cz_dual_channel_metering.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json similarity index 100% rename from tests/components/tuya/fixtures/cz_dual_channel_metering.json rename to tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json diff --git a/tests/components/tuya/fixtures/dj_smart_light_bulb.json b/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json similarity index 100% rename from tests/components/tuya/fixtures/dj_smart_light_bulb.json rename to tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json diff --git a/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json b/tests/components/tuya/fixtures/dlq_0tnvg2xaisqdadcf.json similarity index 100% rename from tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json rename to tests/components/tuya/fixtures/dlq_0tnvg2xaisqdadcf.json diff --git a/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json similarity index 100% rename from tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json rename to tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json diff --git a/tests/components/tuya/fixtures/gyd_night_light.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json similarity index 100% rename from tests/components/tuya/fixtures/gyd_night_light.json rename to tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json diff --git a/tests/components/tuya/fixtures/kg_smart_valve.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json similarity index 100% rename from tests/components/tuya/fixtures/kg_smart_valve.json rename to tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json diff --git a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json similarity index 100% rename from tests/components/tuya/fixtures/kj_bladeless_tower_fan.json rename to tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json diff --git a/tests/components/tuya/fixtures/ks_tower_fan.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json similarity index 100% rename from tests/components/tuya/fixtures/ks_tower_fan.json rename to tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json diff --git a/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json similarity index 100% rename from tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json rename to tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json diff --git a/tests/components/tuya/fixtures/mal_alarm_host.json b/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json similarity index 100% rename from tests/components/tuya/fixtures/mal_alarm_host.json rename to tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json diff --git a/tests/components/tuya/fixtures/mcs_door_sensor.json b/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json similarity index 100% rename from tests/components/tuya/fixtures/mcs_door_sensor.json rename to tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json diff --git a/tests/components/tuya/fixtures/qccdz_ac_charging_control.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json similarity index 100% rename from tests/components/tuya/fixtures/qccdz_ac_charging_control.json rename to tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json diff --git a/tests/components/tuya/fixtures/qxj_weather_station.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json similarity index 100% rename from tests/components/tuya/fixtures/qxj_weather_station.json rename to tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json diff --git a/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json similarity index 100% rename from tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json rename to tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json diff --git a/tests/components/tuya/fixtures/rqbj_gas_sensor.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json similarity index 100% rename from tests/components/tuya/fixtures/rqbj_gas_sensor.json rename to tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json diff --git a/tests/components/tuya/fixtures/sfkzq_valve_controller.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json similarity index 100% rename from tests/components/tuya/fixtures/sfkzq_valve_controller.json rename to tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json diff --git a/tests/components/tuya/fixtures/tdq_4_443.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json similarity index 100% rename from tests/components/tuya/fixtures/tdq_4_443.json rename to tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json diff --git a/tests/components/tuya/fixtures/wk_air_conditioner.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json similarity index 100% rename from tests/components/tuya/fixtures/wk_air_conditioner.json rename to tests/components/tuya/fixtures/wk_aqoouq7x.json diff --git a/tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json similarity index 100% rename from tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json rename to tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json diff --git a/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json similarity index 100% rename from tests/components/tuya/fixtures/wsdcg_temperature_humidity.json rename to tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json diff --git a/tests/components/tuya/fixtures/wxkg_wireless_switch.json b/tests/components/tuya/fixtures/wxkg_l8yaz4um5b3pwyvf.json similarity index 100% rename from tests/components/tuya/fixtures/wxkg_wireless_switch.json rename to tests/components/tuya/fixtures/wxkg_l8yaz4um5b3pwyvf.json diff --git a/tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json similarity index 100% rename from tests/components/tuya/fixtures/ydkt_dolceclima_unsupported.json rename to tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json diff --git a/tests/components/tuya/fixtures/zndb_smart_meter.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json similarity index 100% rename from tests/components/tuya/fixtures/zndb_smart_meter.json rename to tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr index 97076d5e467..73072dcb516 100644 --- a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_alarm-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][alarm_control_panel.multifunction_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_alarm-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][alarm_control_panel.multifunction_alarm-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 267f61aabd0..727e59590a5 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][binary_sensor.aqi_safety-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][binary_sensor.aqi_safety-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', @@ -48,154 +48,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-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.dehumidifier_defrost', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Defrost', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'defrost', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqdefrost', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Dehumidifier Defrost', - }), - 'context': , - 'entity_id': 'binary_sensor.dehumidifier_defrost', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-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.dehumidifier_tank_full', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tank full', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'tankfull', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqtankfull', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Dehumidifier Tank full', - }), - 'context': , - 'entity_id': 'binary_sensor.dehumidifier_tank_full', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-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.dehumidifier_wet', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wet', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wet', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqwet', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Dehumidifier Wet', - }), - 'context': , - 'entity_id': 'binary_sensor.dehumidifier_wet', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -230,7 +83,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_defrost-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -244,7 +97,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_tank_full-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -279,7 +132,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_tank_full-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -293,7 +146,154 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_defrost-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.dehumidifier_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqdefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_tank_full-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.dehumidifier_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_wet-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.dehumidifier_wet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wet', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqwet', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_wet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Wet', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -328,7 +328,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-state] +# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][binary_sensor.door_garage_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -342,7 +342,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-entry] +# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][binary_sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -377,7 +377,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-state] +# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][binary_sensor.gas_sensor_gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 6e93a1b263c..cb535cc5c07 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry] +# name: test_platform_setup_and_discovery[kt_5wnlzekkstwcdsvm][climate.air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -46,7 +46,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state] +# name: test_platform_setup_and_discovery[kt_5wnlzekkstwcdsvm][climate.air_conditioner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 22.0, @@ -74,7 +74,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-entry] +# name: test_platform_setup_and_discovery[wk_aqoouq7x][climate.clima_cucina-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -124,7 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-state] +# name: test_platform_setup_and_discovery[wk_aqoouq7x][climate.clima_cucina-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 27.0, @@ -155,7 +155,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -198,7 +198,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-state] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][climate.wifi_smart_gas_boiler_thermostat-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.9, diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 6ae4781c7c1..aa592b25520 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 48, @@ -50,7 +50,7 @@ 'state': 'open', }) # --- -# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-entry] +# name: test_platform_setup_and_discovery[clkg_nhyj64w2][cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +85,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-state] +# name: test_platform_setup_and_discovery[clkg_nhyj64w2][cover.tapparelle_studio_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 0, diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 5fc3796d109..93cc0cd0b6d 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_device_diagnostics[rqbj_gas_sensor] +# name: test_device_diagnostics[rqbj_4iqe2hsfyd86kwwc] dict({ 'active_time': '2025-06-24T20:33:10+00:00', 'category': 'rqbj', @@ -88,7 +88,7 @@ 'update_time': '2025-06-24T20:33:10+00:00', }) # --- -# name: test_entry_diagnostics[rqbj_gas_sensor] +# name: test_entry_diagnostics[rqbj_4iqe2hsfyd86kwwc] dict({ 'devices': list([ dict({ diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index 085ebd3ec8b..ea19ff486da 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-entry] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-state] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'button', @@ -58,7 +58,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-entry] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +98,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-state] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'button', diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index ff795c150c9..69eb1b467e9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -1,55 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][fan.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.bf3fce6af592f12df3gbgq', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][fan.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -87,7 +37,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][fan.dehumidifer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifer', @@ -103,7 +53,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-entry] +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -139,7 +89,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-state] +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifier ', @@ -153,7 +103,57 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3fce6af592f12df3gbgq', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -192,7 +192,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-state] +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Bree', @@ -210,7 +210,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-entry] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][fan.tower_fan_ca_407g_smart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -251,7 +251,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-state] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][fan.tower_fan_ca_407g_smart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Tower Fan CA-407G Smart', diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 3389f927eb4..25bb1799dc8 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -1,5 +1,115 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][humidifier.dehumidifier-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][humidifier.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][humidifier.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifer', + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][humidifier.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -37,7 +147,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][humidifier.dehumidifier-state] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][humidifier.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 47, @@ -56,113 +166,3 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_humidity': 80, - 'min_humidity': 25, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'humidifier', - 'entity_category': None, - 'entity_id': 'humidifier.dehumidifer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.mock_device_idswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'dehumidifier', - 'friendly_name': 'Dehumidifer', - 'max_humidity': 80, - 'min_humidity': 25, - 'supported_features': , - }), - 'context': , - 'entity_id': 'humidifier.dehumidifer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_humidity': 100, - 'min_humidity': 0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'humidifier', - 'entity_category': None, - 'entity_id': 'humidifier.dehumidifier', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.mock_device_idswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'dehumidifier', - 'friendly_name': 'Dehumidifier ', - 'max_humidity': 100, - 'min_humidity': 0, - 'supported_features': , - }), - 'context': , - 'entity_id': 'humidifier.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 127c764ea0d..83548abf0c3 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_unsupported_device[ydkt_dolceclima_unsupported] +# name: test_unsupported_device[ydkt_jevroj5aguwdbs2e] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index ec8e663f62c..06ad884cfa3 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-entry] +# name: test_platform_setup_and_discovery[clkg_nhyj64w2][light.tapparelle_studio_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -38,7 +38,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-state] +# name: test_platform_setup_and_discovery[clkg_nhyj64w2][light.tapparelle_studio_backlight-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': , @@ -56,7 +56,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-entry] +# name: test_platform_setup_and_discovery[dj_mki13ie507rlry4r][light.garage_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -96,7 +96,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-state] +# name: test_platform_setup_and_discovery[dj_mki13ie507rlry4r][light.garage_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': 138, @@ -119,7 +119,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] +# name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -163,7 +163,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-state] +# name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': None, @@ -192,7 +192,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-entry] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][light.tower_fan_ca_407g_smart_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -231,7 +231,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-state] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][light.tower_fan_ca_407g_smart_backlight-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'color_mode': , diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 1c8af00baff..c6f2bb363b6 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][number.aqi_alarm_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][number.aqi_alarm_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -58,7 +58,7 @@ 'state': '1.0', }) # --- -# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-entry] +# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +98,7 @@ 'unit_of_measurement': '', }) # --- -# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-state] +# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][number.cleverio_pf100_feed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Cleverio PF100 Feed', @@ -116,7 +116,7 @@ 'state': '1.0', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_alarm_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -156,7 +156,7 @@ 'unit_of_measurement': 's', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_alarm_delay-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -175,7 +175,7 @@ 'state': '20.0', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_arm_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -215,7 +215,7 @@ 'unit_of_measurement': 's', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_arm_delay-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -234,7 +234,7 @@ 'state': '15.0', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_siren_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -274,7 +274,7 @@ 'unit_of_measurement': 'min', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_siren_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -293,7 +293,7 @@ 'state': '3.0', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -333,7 +333,7 @@ 'unit_of_measurement': '℃', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 0f530184122..4bd058517be 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] +# name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] +# name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kitchen Blinds Motor mode', @@ -56,7 +56,7 @@ 'state': 'forward', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][select.aqi_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +98,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][select.aqi_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI Volume', @@ -117,68 +117,7 @@ 'state': 'low', }) # --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'cancel', - '1h', - '2h', - '3h', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.dehumidifier_countdown', - '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': 'Countdown', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'countdown', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqcountdown_set', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier Countdown', - 'options': list([ - 'cancel', - '1h', - '2h', - '3h', - ]), - }), - 'context': , - 'entity_id': 'select.dehumidifier_countdown', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'cancel', - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][select.dehumidifer_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -220,7 +159,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][select.dehumidifer_countdown-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifer Countdown', @@ -239,7 +178,68 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][select.dehumidifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifier_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][select.dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -279,7 +279,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-state] +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][select.smart_odor_eliminator_pro_odor_elimination_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', @@ -296,7 +296,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -340,7 +340,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-state] +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Bree Countdown', @@ -361,7 +361,7 @@ 'state': 'cancel', }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-entry] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -402,7 +402,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-state] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': '4-433 Power on behavior', diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 80051a08396..882839a6665 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -36,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -52,7 +52,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_formaldehyde-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +89,7 @@ 'unit_of_measurement': 'mg/m3', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_formaldehyde-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI Formaldehyde', @@ -104,7 +104,7 @@ 'state': '0.002', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -141,7 +141,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -157,7 +157,7 @@ 'state': '53.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -197,7 +197,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -213,7 +213,7 @@ 'state': '26.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_volatile_organic_compounds-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -250,7 +250,7 @@ 'unit_of_measurement': 'mg/m³', }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_volatile_organic_compounds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds', @@ -266,60 +266,7 @@ 'state': '0.018', }) # --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_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.dehumidifier_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqhumidity_indoor', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Dehumidifier Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.dehumidifier_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '47.0', - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][sensor.dehumidifer_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -356,7 +303,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][sensor.dehumidifer_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -372,7 +319,60 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-entry] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][sensor.dehumidifier_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.dehumidifier_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqhumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][sensor.dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -409,7 +409,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-state] +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -425,7 +425,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-entry] +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -460,7 +460,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-state] +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Smart Odor Eliminator-Pro Status', @@ -473,7 +473,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] +# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -510,7 +510,7 @@ 'unit_of_measurement': '', }) # --- -# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-state] +# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][sensor.cleverio_pf100_last_amount-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Cleverio PF100 Last amount', @@ -525,7 +525,7 @@ 'state': '2.0', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -565,7 +565,7 @@ 'unit_of_measurement': 'min', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_filter_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -581,7 +581,7 @@ 'state': '18965.0', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_uv_runtime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -621,7 +621,7 @@ 'unit_of_measurement': 's', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_uv_runtime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -637,7 +637,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_level-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -672,7 +672,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_level-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain Water level', @@ -685,7 +685,7 @@ 'state': 'level_3', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_pump_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -725,7 +725,7 @@ 'unit_of_measurement': 'min', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -741,7 +741,7 @@ 'state': '18965.0', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_usage_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -781,7 +781,7 @@ 'unit_of_measurement': 'min', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -797,7 +797,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_current-entry] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -840,7 +840,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_current-state] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -856,7 +856,7 @@ 'state': '0.083', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-entry] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -896,7 +896,7 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-state] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -912,7 +912,7 @@ 'state': '6.4', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_voltage-entry] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -955,7 +955,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_voltage-state] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -971,7 +971,7 @@ 'state': '121.7', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-entry] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1014,7 +1014,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -1030,7 +1030,7 @@ 'state': '2.198', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-entry] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1070,7 +1070,7 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1086,7 +1086,7 @@ 'state': '495.3', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-entry] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1129,7 +1129,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1145,7 +1145,7 @@ 'state': '231.4', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_current-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1185,7 +1185,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_current-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -1201,7 +1201,7 @@ 'state': '0.637', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_power-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1241,7 +1241,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_power-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1257,7 +1257,7 @@ 'state': '0.108', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_voltage-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1297,7 +1297,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_voltage-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1313,7 +1313,7 @@ 'state': '221.1', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_current-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1353,7 +1353,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_current-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -1369,7 +1369,7 @@ 'state': '11.203', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_power-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1409,7 +1409,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_power-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1425,7 +1425,7 @@ 'state': '2.41', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_voltage-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1465,7 +1465,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_voltage-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1481,7 +1481,7 @@ 'state': '218.7', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_current-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1521,7 +1521,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_current-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -1537,7 +1537,7 @@ 'state': '0.913', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_power-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1577,7 +1577,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_power-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1593,7 +1593,7 @@ 'state': '0.092', }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_voltage-entry] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1633,7 +1633,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_voltage-state] +# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1649,7 +1649,7 @@ 'state': '220.4', }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-entry] +# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1686,7 +1686,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-state] +# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][sensor.door_garage_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -1702,220 +1702,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.frysen_battery_state', - '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': 'Battery state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_state', - 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Frysen Battery state', - }), - 'context': , - 'entity_id': 'sensor.frysen_battery_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'high', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Frysen Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.frysen_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '38.0', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_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.frysen_probe_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Probe temperature', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temperature_external', - 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Frysen Probe temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.frysen_probe_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-13.0', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temperature', - 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Frysen Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.frysen_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '22.2', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1950,7 +1737,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', @@ -1963,7 +1750,7 @@ 'state': 'high', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2000,7 +1787,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2016,7 +1803,7 @@ 'state': '52.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2053,7 +1840,7 @@ 'unit_of_measurement': 'lx', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'illuminance', @@ -2069,7 +1856,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2109,7 +1896,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2125,7 +1912,7 @@ 'state': '-40.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2165,7 +1952,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2181,7 +1968,220 @@ 'state': '24.0', }) # --- -# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-entry] +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frysen_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_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.frysen_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_probe_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.frysen_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_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.frysen_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2218,7 +2218,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-state] +# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][sensor.gas_sensor_gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Gas sensor Gas', @@ -2334,7 +2334,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-entry] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][sensor.wifi_smart_gas_boiler_thermostat_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2371,7 +2371,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-state] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][sensor.wifi_smart_gas_boiler_thermostat_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -2387,7 +2387,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-entry] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2424,7 +2424,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-state] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -2440,7 +2440,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_humidity-entry] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2477,7 +2477,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_humidity-state] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2493,7 +2493,7 @@ 'state': '47.0', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_temperature-entry] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2533,7 +2533,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_temperature-state] +# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2549,7 +2549,7 @@ 'state': '18.5', }) # --- -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_battery-entry] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][sensor.bathroom_smart_switch_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2586,7 +2586,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_battery-state] +# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][sensor.bathroom_smart_switch_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -2602,7 +2602,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-entry] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2642,7 +2642,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-state] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -2658,7 +2658,7 @@ 'state': '5.62', }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_power-entry] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2698,7 +2698,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_power-state] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -2714,7 +2714,7 @@ 'state': '1.185', }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_voltage-entry] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2754,7 +2754,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_voltage-state] +# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 8a6faa31c43..7b6afe9dc60 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-entry] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][siren.aqi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-state] +# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][siren.aqi-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI', diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index e21fe9c91bd..2c2325e9ed8 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1,54 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][switch.dehumidifier_child_lock-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': , - 'entity_id': 'switch.dehumidifier_child_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:account-lock', - 'original_name': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][switch.dehumidifier_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier Child lock', - 'icon': 'mdi:account-lock', - }), - 'context': , - 'entity_id': 'switch.dehumidifier_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -83,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifer Child lock', @@ -97,7 +48,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-entry] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_ionizer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -132,7 +83,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-state] +# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_ionizer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifer Ionizer', @@ -146,7 +97,56 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-entry] +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][switch.dehumidifier_child_lock-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': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][switch.dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][switch.smart_odor_eliminator_pro_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -181,7 +181,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-state] +# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][switch.smart_odor_eliminator_pro_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Smart Odor Eliminator-Pro Switch', @@ -194,7 +194,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -229,7 +229,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_filter_reset-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain Filter reset', @@ -242,7 +242,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_power-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -277,7 +277,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_power-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain Power', @@ -290,7 +290,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -325,7 +325,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain Reset of water usage days', @@ -338,7 +338,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_uv_sterilization-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_uv_sterilization-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -373,7 +373,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_uv_sterilization-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_uv_sterilization-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain UV sterilization', @@ -386,7 +386,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_water_pump_reset-entry] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_water_pump_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -421,7 +421,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_water_pump_reset-state] +# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_water_pump_reset-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'PIXI Smart Drinking Fountain Water pump reset', @@ -434,7 +434,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_1-entry] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -469,7 +469,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_1-state] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -483,7 +483,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_2-entry] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -518,7 +518,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_2-state] +# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -532,7 +532,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-entry] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -567,7 +567,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': '一路带计量磁保持通断器 Child lock', @@ -580,7 +580,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-entry] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -615,7 +615,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] +# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': '一路带计量磁保持通断器 Switch', @@ -628,7 +628,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-entry] +# name: test_platform_setup_and_discovery[kg_gbm9ata1zrzaez4a][switch.qt_switch_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -663,7 +663,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-state] +# name: test_platform_setup_and_discovery[kg_gbm9ata1zrzaez4a][switch.qt_switch_switch_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -677,7 +677,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-entry] +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -712,7 +712,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-state] +# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Bree Power', @@ -725,7 +725,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-entry] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][switch.tower_fan_ca_407g_smart_ionizer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -760,7 +760,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-state] +# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][switch.tower_fan_ca_407g_smart_ionizer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', @@ -773,7 +773,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_arm_beep-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -808,7 +808,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_arm_beep-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Multifunction alarm Arm beep', @@ -821,7 +821,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-entry] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_siren-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -856,7 +856,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-state] +# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_siren-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Multifunction alarm Siren', @@ -869,7 +869,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-entry] +# name: test_platform_setup_and_discovery[qccdz_7bvgooyjhiua1yyq][switch.ac_charging_control_box_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -904,7 +904,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-state] +# name: test_platform_setup_and_discovery[qccdz_7bvgooyjhiua1yyq][switch.ac_charging_control_box_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AC charging control box Switch', @@ -917,7 +917,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] +# name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -952,7 +952,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-state] +# name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Sprinkler Cesare Switch', @@ -965,7 +965,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_1-entry] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1000,7 +1000,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_1-state] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -1014,7 +1014,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_2-entry] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1049,7 +1049,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_2-state] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -1063,7 +1063,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_3-entry] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1098,7 +1098,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_3-state] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -1112,7 +1112,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_4-entry] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1147,7 +1147,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_4-state] +# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -1209,7 +1209,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-entry] +# name: test_platform_setup_and_discovery[wk_aqoouq7x][switch.clima_cucina_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1244,7 +1244,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-state] +# name: test_platform_setup_and_discovery[wk_aqoouq7x][switch.clima_cucina_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Clima cucina Child lock', @@ -1257,7 +1257,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1292,7 +1292,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-state] +# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][switch.wifi_smart_gas_boiler_thermostat_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock', diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 9045b28bfa9..85dd644b79c 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -60,7 +60,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["cs_arete_two_12l_dehumidifier_air_purifier"], + ["cs_zibqa9dutqyaxym2"], ) @pytest.mark.parametrize( ("fault_value", "tankfull", "defrost", "wet"), @@ -84,7 +84,7 @@ async def test_bitmap( defrost: str, wet: str, ) -> None: - """Test BITMAP fault sensor on cs_arete_two_12l_dehumidifier_air_purifier.""" + """Test BITMAP fault sensor on cs_zibqa9dutqyaxym2.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == "off" diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index e8aee3f4f96..01fdf469e27 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -66,7 +66,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["kt_serenelife_slpac905wuk_air_conditioner"], + ["kt_5wnlzekkstwcdsvm"], ) async def test_set_temperature( hass: HomeAssistant, @@ -96,7 +96,7 @@ async def test_set_temperature( @pytest.mark.parametrize( "mock_device_code", - ["kt_serenelife_slpac905wuk_air_conditioner"], + ["kt_5wnlzekkstwcdsvm"], ) async def test_fan_mode_windspeed( hass: HomeAssistant, @@ -127,7 +127,7 @@ async def test_fan_mode_windspeed( @pytest.mark.parametrize( "mock_device_code", - ["kt_serenelife_slpac905wuk_air_conditioner"], + ["kt_5wnlzekkstwcdsvm"], ) async def test_fan_mode_no_valid_code( hass: HomeAssistant, @@ -161,7 +161,7 @@ async def test_fan_mode_no_valid_code( @pytest.mark.parametrize( "mock_device_code", - ["kt_serenelife_slpac905wuk_air_conditioner"], + ["kt_5wnlzekkstwcdsvm"], ) async def test_set_humidity_not_supported( hass: HomeAssistant, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 24e43dcccec..20d84878a58 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -67,7 +67,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_open_service( @@ -101,7 +101,7 @@ async def test_open_service( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_close_service( @@ -135,7 +135,7 @@ async def test_close_service( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) async def test_set_position( hass: HomeAssistant, @@ -168,7 +168,7 @@ async def test_set_position( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) @pytest.mark.parametrize( ("percent_control", "percent_state"), @@ -202,7 +202,7 @@ async def test_percent_state_on_cover( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) async def test_set_tilt_position_not_supported( hass: HomeAssistant, diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py index 2009f117efb..f07c2faa229 100644 --- a/tests/components/tuya/test_diagnostics.py +++ b/tests/components/tuya/test_diagnostics.py @@ -22,7 +22,7 @@ from tests.components.diagnostics import ( from tests.typing import ClientSessionGenerator -@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +@pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) async def test_entry_diagnostics( hass: HomeAssistant, mock_manager: ManagerCompat, @@ -43,7 +43,7 @@ async def test_entry_diagnostics( ) -@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +@pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"]) async def test_device_diagnostics( hass: HomeAssistant, mock_manager: ManagerCompat, diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index d4996bcd32a..bd3604b25dd 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -65,7 +65,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["cs_arete_two_12l_dehumidifier_air_purifier"], + ["cs_zibqa9dutqyaxym2"], ) async def test_turn_on( hass: HomeAssistant, @@ -92,7 +92,7 @@ async def test_turn_on( @pytest.mark.parametrize( "mock_device_code", - ["cs_arete_two_12l_dehumidifier_air_purifier"], + ["cs_zibqa9dutqyaxym2"], ) async def test_turn_off( hass: HomeAssistant, @@ -119,7 +119,7 @@ async def test_turn_off( @pytest.mark.parametrize( "mock_device_code", - ["cs_arete_two_12l_dehumidifier_air_purifier"], + ["cs_zibqa9dutqyaxym2"], ) async def test_set_humidity( hass: HomeAssistant, @@ -149,7 +149,7 @@ async def test_set_humidity( @pytest.mark.parametrize( "mock_device_code", - ["cs_smart_dry_plus"], + ["cs_vmxuxszzjwp5smli"], ) async def test_turn_on_unsupported( hass: HomeAssistant, @@ -179,7 +179,7 @@ async def test_turn_on_unsupported( @pytest.mark.parametrize( "mock_device_code", - ["cs_smart_dry_plus"], + ["cs_vmxuxszzjwp5smli"], ) async def test_turn_off_unsupported( hass: HomeAssistant, @@ -209,7 +209,7 @@ async def test_turn_off_unsupported( @pytest.mark.parametrize( "mock_device_code", - ["cs_smart_dry_plus"], + ["cs_vmxuxszzjwp5smli"], ) async def test_set_humidity_unsupported( hass: HomeAssistant, diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index 9e9855f9fac..ab96f58ecd0 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -15,7 +15,7 @@ from . import initialize_entry from tests.common import MockConfigEntry -@pytest.mark.parametrize("mock_device_code", ["ydkt_dolceclima_unsupported"]) +@pytest.mark.parametrize("mock_device_code", ["ydkt_jevroj5aguwdbs2e"]) async def test_unsupported_device( hass: HomeAssistant, mock_manager: ManagerCompat, diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 0d4706a5563..e3586613876 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -64,7 +64,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["dj_smart_light_bulb"], + ["dj_mki13ie507rlry4r"], ) async def test_turn_on_white( hass: HomeAssistant, @@ -98,7 +98,7 @@ async def test_turn_on_white( @pytest.mark.parametrize( "mock_device_code", - ["dj_smart_light_bulb"], + ["dj_mki13ie507rlry4r"], ) async def test_turn_off( hass: HomeAssistant, diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index b6c7b1f6de5..f28d6414170 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -59,7 +59,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["mal_alarm_host"], + ["mal_gyitctrjj1kefxp2"], ) async def test_set_value( hass: HomeAssistant, @@ -89,7 +89,7 @@ async def test_set_value( @pytest.mark.parametrize( "mock_device_code", - ["mal_alarm_host"], + ["mal_gyitctrjj1kefxp2"], ) async def test_set_value_no_function( hass: HomeAssistant, diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index cd1d926ff76..475fab30b90 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -62,7 +62,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) async def test_select_option( hass: HomeAssistant, @@ -92,7 +92,7 @@ async def test_select_option( @pytest.mark.parametrize( "mock_device_code", - ["cl_am43_corded_motor_zigbee_cover"], + ["cl_zah67ekd"], ) async def test_select_invalid_option( hass: HomeAssistant, From 1a9cae0f89f0a99819b110d32859abe53fb23512 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 4 Aug 2025 04:17:25 -0400 Subject: [PATCH 0864/1118] Bump ZHA to 0.0.65 (#149922) --- 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 ec08c4f5d9d..facde4ead3a 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.64"], + "requirements": ["zha==0.0.65"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index d0df5b416d0..d69ce8368e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3203,7 +3203,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.64 +zha==0.0.65 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa48c183a3b..5e10f227725 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.64 +zha==0.0.65 # homeassistant.components.zwave_js zwave-js-server-python==0.67.0 From 1a54d566f87b81fe8e337b580467c9daa5c804c7 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Mon, 4 Aug 2025 04:26:11 -0400 Subject: [PATCH 0865/1118] Apple vendor name update (#149845) --- homeassistant/components/thread/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index d4e47c31dd2..4bd4c6e81f7 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Amazon": "amazon", + "Apple": "apple", "Apple Inc.": "apple", "Aqara": "aqara_gateway", "eero": "eero", From 6fa9d4240113479c7a8b38289df5390d5295698b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Mon, 4 Aug 2025 11:05:32 +0200 Subject: [PATCH 0866/1118] Airthings ContextVar warning (#149930) --- homeassistant/components/airthings/__init__.py | 7 ++----- homeassistant/components/airthings/coordinator.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 175fd320062..04c666dc5bc 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -7,21 +7,18 @@ import logging from airthings import Airthings -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_SECRET -from .coordinator import AirthingsDataUpdateCoordinator +from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) -type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool: """Set up Airthings from a config entry.""" @@ -31,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> async_get_clientsession(hass), ) - coordinator = AirthingsDataUpdateCoordinator(hass, airthings) + coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airthings/coordinator.py b/homeassistant/components/airthings/coordinator.py index 6172dc0b6ef..9e15e4a0c5d 100644 --- a/homeassistant/components/airthings/coordinator.py +++ b/homeassistant/components/airthings/coordinator.py @@ -5,6 +5,7 @@ import logging from airthings import Airthings, AirthingsDevice, AirthingsError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,23 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=6) +type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator] + class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]): """Coordinator for Airthings data updates.""" - def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None: + def __init__( + self, + hass: HomeAssistant, + airthings: Airthings, + config_entry: AirthingsConfigEntry, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_method=self._update_method, update_interval=SCAN_INTERVAL, From 3dda1685dc74ab4dfa1b4ec63715ad3c0d53f087 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:08:43 +0200 Subject: [PATCH 0867/1118] Add Tuya snapshots for pc and pir category (#149931) --- tests/components/tuya/__init__.py | 23 ++ .../tuya/fixtures/pc_t2afic7i3v1bwhfp.json | 44 ++++ .../tuya/fixtures/pc_trjopo1vdlt9q1tg.json | 86 ++++++ .../tuya/fixtures/pir_3amxzozho9xp4mkh.json | 44 ++++ .../tuya/fixtures/pir_fcdjzz3s.json | 48 ++++ .../tuya/fixtures/pir_wqz93nrdomectyoz.json | 39 +++ .../tuya/snapshots/test_binary_sensor.ambr | 245 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 149 +++++++++++ .../tuya/snapshots/test_switch.ambr | 196 ++++++++++++++ 9 files changed, 874 insertions(+) create mode 100644 tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json create mode 100644 tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json create mode 100644 tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json create mode 100644 tests/components/tuya/fixtures/pir_fcdjzz3s.json create mode 100644 tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 040ee1fec2f..371dc4dcfa8 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -123,6 +123,29 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "pc_t2afic7i3v1bwhfp": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SWITCH, + ], + "pc_trjopo1vdlt9q1tg": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SWITCH, + ], + "pir_3amxzozho9xp4mkh": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + "pir_fcdjzz3s": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + "pir_wqz93nrdomectyoz": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], "qccdz_7bvgooyjhiua1yyq": [ # https://github.com/home-assistant/core/issues/136207 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json new file mode 100644 index 00000000000..4ed7ecf0373 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json @@ -0,0 +1,44 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf2206da15147500969d6e", + "name": "Bubbelbad", + "category": "pc", + "product_id": "t2afic7i3v1bwhfp", + "product_name": "Garden Spike(EU)", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-02-02T15:10:03+00:00", + "create_time": "2022-02-02T15:10:03+00:00", + "update_time": "2022-02-02T15:10:03+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json new file mode 100644 index 00000000000..99929616ec7 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "15727703c4dd5709cd78", + "name": "Terras", + "category": "pc", + "product_id": "trjopo1vdlt9q1tg", + "product_name": "Garden Spike(FR)", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T08:42:44+00:00", + "create_time": "2023-01-18T08:42:44+00:00", + "update_time": "2023-01-18T08:42:44+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "switch_2": false, + "countdown_1": 0, + "countdown_2": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json new file mode 100644 index 00000000000..98843da5614 --- /dev/null +++ b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json @@ -0,0 +1,44 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "73486068483fda10d633", + "name": "rat trap hedge", + "category": "pir", + "product_id": "3amxzozho9xp4mkh", + "product_name": "Smart Motion Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-11-17T11:36:06+00:00", + "create_time": "2021-11-17T11:36:06+00:00", + "update_time": "2021-11-17T11:36:06+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "pir": "pir", + "battery_state": "low", + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_fcdjzz3s.json b/tests/components/tuya/fixtures/pir_fcdjzz3s.json new file mode 100644 index 00000000000..65740a4106c --- /dev/null +++ b/tests/components/tuya/fixtures/pir_fcdjzz3s.json @@ -0,0 +1,48 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf445324326cbde7c5rg7b", + "name": "Motion sensor lidl zigbee", + "category": "pir", + "product_id": "fcdjzz3s", + "product_name": "Motion sensor", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2022-09-09T07:24:07+00:00", + "create_time": "2022-09-09T07:24:07+00:00", + "update_time": "2022-09-09T07:24:07+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "pir": "nopir", + "battery_percentage": 85, + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json new file mode 100644 index 00000000000..e4122ee5f9d --- /dev/null +++ b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "20401777500291cfe3a2", + "name": "PIR outside stairs", + "category": "pir", + "product_id": "wqz93nrdomectyoz", + "product_name": "Smart PIR sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-26T09:56:39+00:00", + "create_time": "2023-01-26T09:56:39+00:00", + "update_time": "2023-01-26T09:56:39+00:00", + "function": {}, + "status_range": { + "pir": { + "type": "Enum", + "value": { + "range": ["pir"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "pir": "pir", + "battery_state": "middle" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 727e59590a5..bdade20fc9e 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -342,6 +342,251 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_motion-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.rat_trap_hedge_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.73486068483fda10d633pir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'rat trap hedge Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_tamper-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.rat_trap_hedge_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.73486068483fda10d633temper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'rat trap hedge Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_motion-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.motion_sensor_lidl_zigbee_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf445324326cbde7c5rg7bpir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Motion sensor lidl zigbee Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_tamper-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.motion_sensor_lidl_zigbee_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf445324326cbde7c5rg7btemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Motion sensor lidl zigbee Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][binary_sensor.pir_outside_stairs_motion-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.pir_outside_stairs_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.20401777500291cfe3a2pir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][binary_sensor.pir_outside_stairs_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'PIR outside stairs Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.pir_outside_stairs_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][binary_sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 882839a6665..740c8db3690 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1702,6 +1702,155 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.73486068483fda10d633battery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'rat trap hedge Battery state', + }), + 'context': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][sensor.motion_sensor_lidl_zigbee_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.motion_sensor_lidl_zigbee_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf445324326cbde7c5rg7bbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[pir_fcdjzz3s][sensor.motion_sensor_lidl_zigbee_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Motion sensor lidl zigbee Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][sensor.pir_outside_stairs_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.20401777500291cfe3a2battery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][sensor.pir_outside_stairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIR outside stairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 2c2325e9ed8..c810649801a 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -869,6 +869,202 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-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.bubbelbad_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bf2206da15147500969d6eswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 1', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_2-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.bubbelbad_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bf2206da15147500969d6eswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 2', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_1-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.terras_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.15727703c4dd5709cd78switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 1', + }), + 'context': , + 'entity_id': 'switch.terras_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_2-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.terras_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.15727703c4dd5709cd78switch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 2', + }), + 'context': , + 'entity_id': 'switch.terras_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[qccdz_7bvgooyjhiua1yyq][switch.ac_charging_control_box_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 83f22497ae0ade469e244d1f9aed618a7239a01e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:09:02 +0200 Subject: [PATCH 0868/1118] Bump actions/ai-inference from 1.2.3 to 1.2.4 (#149929) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 0facf6fdf77..fd7ed1a38a9 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.3 + uses: actions/ai-inference@v1.2.4 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index b1ce58c4b41..eefc896bfcb 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.3 + uses: actions/ai-inference@v1.2.4 with: model: openai/gpt-4o-mini system-prompt: | From 46ed8a73fc5a1340ce76816994b9cd08afdbb410 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Aug 2025 11:09:18 +0200 Subject: [PATCH 0869/1118] Bump automower-ble to 0.2.7 (#149928) --- homeassistant/components/husqvarna_automower_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/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 6eb618cbb04..50430c2a9fa 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.1"] + "requirements": ["automower-ble==0.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index d69ce8368e2..772a7372f31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -560,7 +560,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.1 +automower-ble==0.2.7 # homeassistant.components.generic # homeassistant.components.stream diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e10f227725..c8744c9e3c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -515,7 +515,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.1 +automower-ble==0.2.7 # homeassistant.components.generic # homeassistant.components.stream From aa8e4c1c1535c61052bfcbcad19a416b1d87fbb6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:11:06 +0200 Subject: [PATCH 0870/1118] Add Tuya snapshots for sgbj, sp, wfcon and ywbj category (#149933) --- tests/components/tuya/__init__.py | 26 + .../tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json | 69 +++ .../tuya/fixtures/sp_drezasavompxpcgm.json | 182 +++++++ .../tuya/fixtures/sp_rjKXWRohlvOTyLBu.json | 213 ++++++++ .../tuya/fixtures/wfcon_b25mh8sxawsgndck.json | 23 + .../tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json | 65 +++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++ .../components/tuya/snapshots/test_light.ambr | 114 +++++ .../tuya/snapshots/test_number.ambr | 58 +++ .../tuya/snapshots/test_select.ambr | 466 +++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 101 ++++ .../components/tuya/snapshots/test_siren.ambr | 49 ++ .../tuya/snapshots/test_switch.ambr | 480 ++++++++++++++++++ 13 files changed, 1895 insertions(+) create mode 100644 tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json create mode 100644 tests/components/tuya/fixtures/sp_drezasavompxpcgm.json create mode 100644 tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json create mode 100644 tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json create mode 100644 tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 371dc4dcfa8..260ba741cb3 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -167,6 +167,24 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, ], + "sgbj_ulv4nnue7gqp0rjk": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.NUMBER, + Platform.SELECT, + Platform.SIREN, + ], + "sp_drezasavompxpcgm": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + Platform.SELECT, + Platform.SWITCH, + ], + "sp_rjKXWRohlvOTyLBu": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + Platform.SELECT, + Platform.SWITCH, + ], "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, @@ -178,6 +196,9 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "wfcon_b25mh8sxawsgndck": [ + # https://github.com/home-assistant/core/issues/149704 + ], "wk_aqoouq7x": [ # https://github.com/home-assistant/core/issues/146263 Platform.CLIMATE, @@ -203,6 +224,11 @@ DEVICE_MOCKS = { # https://github.com/orgs/home-assistant/discussions/288 # unsupported device - no platforms ], + "ywbj_gf9dejhmzffgdyfj": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json new file mode 100644 index 00000000000..a3068983c87 --- /dev/null +++ b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json @@ -0,0 +1,69 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf0984adfeffe10d5a3ofd", + "name": "Siren veranda ", + "category": "sgbj", + "product_id": "ulv4nnue7gqp0rjk", + "product_name": "Siren Sensor", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2020-05-05T20:15:05+00:00", + "create_time": "2020-05-05T20:15:05+00:00", + "update_time": "2020-05-05T20:15:05+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 30, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 30, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "alarm_volume": "middle", + "alarm_time": 10, + "alarm_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json new file mode 100644 index 00000000000..a6543eac5ea --- /dev/null +++ b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json @@ -0,0 +1,182 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf7b8e59f8cd49f425mmfm", + "name": "CAM GARAGE", + "category": "sp", + "product_id": "drezasavompxpcgm", + "product_name": "Indoor camera ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-07-26T13:26:21+00:00", + "create_time": "2021-07-26T13:26:21+00:00", + "update_time": "2021-07-26T13:26:21+00:00", + "function": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "decibel_upload": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "basic_indicator": true, + "basic_flip": false, + "basic_osd": false, + "motion_sensitivity": 0, + "basic_nightvision": 0, + "sd_storge": "0|0|0", + "sd_status": 5, + "sd_format": false, + "movement_detect_pic": "**REDACTED**", + "sd_format_state": -20000, + "motion_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 0, + "decibel_upload": 1696802404, + "record_switch": false, + "record_mode": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json new file mode 100644 index 00000000000..9a7bb9f1eca --- /dev/null +++ b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json @@ -0,0 +1,213 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf9d5b7ea61ea4c9a6rom9", + "name": "CAM PORCH", + "category": "sp", + "product_id": "rjKXWRohlvOTyLBu", + "product_name": "Indoor cam Pan/Tilt ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2020-07-04T07:41:28+00:00", + "create_time": "2020-07-04T07:41:28+00:00", + "update_time": "2020-07-04T07:41:28+00:00", + "function": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_timer_setting": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "motion_timer_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "basic_indicator": { + "type": "Boolean", + "value": {} + }, + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_timer_setting": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "motion_timer_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "decibel_upload": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "basic_indicator": false, + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 2, + "sd_storge": "100|0|100", + "sd_status": 5, + "sd_format": true, + "motion_timer_setting": "00:00|06:00", + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 100, + "ptz_control": 6, + "motion_switch": false, + "motion_timer_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 1, + "decibel_upload": 1750049151, + "record_switch": false, + "record_mode": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json new file mode 100644 index 00000000000..2fa798b2f60 --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf63312cdd4722555bmsuv", + "name": "ZigBee Gateway", + "category": "wfcon", + "product_id": "b25mh8sxawsgndck", + "product_name": "ZigBee Gateway", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-12-29T15:54:11+00:00", + "create_time": "2020-12-29T15:54:11+00:00", + "update_time": "2020-12-29T15:54:11+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json new file mode 100644 index 00000000000..f80b0cd5cd1 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "8670375210521cf1349c", + "name": " Smoke detector upstairs ", + "category": "ywbj", + "product_id": "gf9dejhmzffgdyfj", + "product_name": "Smart Smoke Alarm", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-11-09T13:21:37+00:00", + "create_time": "2021-11-09T13:21:37+00:00", + "update_time": "2021-11-09T13:21:37+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "lifecycle": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "lifecycle": true, + "battery_state": "low", + "battery_percentage": 16, + "muffling": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index bdade20fc9e..821564240eb 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -636,3 +636,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-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.smoke_detector_upstairs_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.8670375210521cf1349csmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': ' Smoke detector upstairs Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_detector_upstairs_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 06ad884cfa3..26648b0f2cb 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -249,6 +249,120 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][light.cam_garage_indicator_light-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': , + 'entity_id': 'light.cam_garage_indicator_light', + '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': 'Indicator light', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_indicator', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][light.cam_garage_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'CAM GARAGE Indicator light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.cam_garage_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][light.cam_porch_indicator_light-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': , + 'entity_id': 'light.cam_porch_indicator_light', + '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': 'Indicator light', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_indicator', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][light.cam_porch_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'CAM PORCH Indicator light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.cam_porch_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index c6f2bb363b6..abfde43a875 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -293,6 +293,64 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.siren_veranda_time', + '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': 'Time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time', + 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_time', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda Time', + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.siren_veranda_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 4bd058517be..3a30e8dceee 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -361,6 +361,472 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.siren_veranda_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.siren_veranda_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_night_vision', + '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': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + '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': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9motion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9record_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + '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': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9decibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 740c8db3690..91cc9e4739b 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2751,6 +2751,107 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_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.smoke_detector_upstairs_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.8670375210521cf1349cbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': ' Smoke detector upstairs Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.8670375210521cf1349cbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': ' Smoke detector upstairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 7b6afe9dc60..5c46c2bbd19 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -48,3 +48,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][siren.siren_veranda-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.siren_veranda', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][siren.siren_veranda-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.siren_veranda', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index c810649801a..57a3fef5c6d 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1161,6 +1161,486 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_flip-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': , + 'entity_id': 'switch.cam_garage_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Flip', + }), + 'context': , + 'entity_id': 'switch.cam_garage_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_garage_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_sound_detection-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': , + 'entity_id': 'switch.cam_garage_sound_detection', + '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': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_garage_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_time_watermark-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': , + 'entity_id': 'switch.cam_garage_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_garage_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_video_recording-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': , + 'entity_id': 'switch.cam_garage_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_garage_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_flip-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': , + 'entity_id': 'switch.cam_porch_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Flip', + }), + 'context': , + 'entity_id': 'switch.cam_porch_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9motion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_porch_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_sound_detection-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': , + 'entity_id': 'switch.cam_porch_sound_detection', + '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': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9decibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_porch_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_time_watermark-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': , + 'entity_id': 'switch.cam_porch_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_porch_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_video_recording-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': , + 'entity_id': 'switch.cam_porch_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9record_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_porch_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From e62e3778f3163fa6b002ea35fda3f58de7213a2f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:14:11 +0200 Subject: [PATCH 0871/1118] Add Tuya snapshots for hps category (#149936) --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/hps_2aaelwxk.json | 186 ++++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 +++++ .../tuya/snapshots/test_number.ambr | 176 +++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 tests/components/tuya/fixtures/hps_2aaelwxk.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 260ba741cb3..4a9d2acd772 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -92,6 +92,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, ], + "hps_2aaelwxk": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.BINARY_SENSOR, + Platform.NUMBER, + ], "kg_gbm9ata1zrzaez4a": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/hps_2aaelwxk.json b/tests/components/tuya/fixtures/hps_2aaelwxk.json new file mode 100644 index 00000000000..4e5066e77f4 --- /dev/null +++ b/tests/components/tuya/fixtures/hps_2aaelwxk.json @@ -0,0 +1,186 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf78687ad321a3aeb8a73m", + "name": "Human presence Office", + "category": "hps", + "product_id": "2aaelwxk", + "product_name": "Human presence sensor", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-12-31T14:40:17+00:00", + "create_time": "2023-12-31T14:40:17+00:00", + "update_time": "2023-12-31T14:40:17+00:00", + "function": { + "sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "near_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "presence_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 28800, + "scale": 0, + "step": 1 + } + }, + "motionless_far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "motionless_sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "indicator": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "presence_state": { + "type": "Enum", + "value": { + "range": ["none", "presence"] + } + }, + "sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "near_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "human_motion_state": { + "type": "Enum", + "value": { + "range": ["none", "large_move", "small_move"] + } + }, + "presence_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 28800, + "scale": 0, + "step": 1 + } + }, + "motionless_far_detection": { + "type": "Integer", + "value": { + "unit": "cm", + "min": 0, + "max": 600, + "scale": 0, + "step": 1 + } + }, + "motionless_sensitivity": { + "type": "Integer", + "value": { + "unit": "x", + "min": 0, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "illuminance_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 6000, + "scale": 0, + "step": 1 + } + }, + "indicator": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "presence_state": "none", + "sensitivity": 3, + "near_detection": 40, + "far_detection": 220, + "human_motion_state": "none", + "presence_time": 30, + "motionless_far_detection": 30, + "motionless_sensitivity": 7, + "illuminance_value": 0, + "indicator": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 821564240eb..7cb613ebbf2 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -293,6 +293,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][binary_sensor.human_presence_office_occupancy-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.human_presence_office_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf78687ad321a3aeb8a73mpresence_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][binary_sensor.human_presence_office_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Human presence Office Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.human_presence_office_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index abfde43a875..9a04b9dd78c 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -116,6 +116,182 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_far_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_far_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Far detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'far_detection', + 'unique_id': 'tuya.bf78687ad321a3aeb8a73mfar_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_far_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Far detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_far_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.0', + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_near_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_near_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Near detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'near_detection', + 'unique_id': 'tuya.bf78687ad321a3aeb8a73mnear_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_near_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Near detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_near_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_sensitivity', + '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': 'Sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'tuya.bf78687ad321a3aeb8a73msensitivity', + 'unit_of_measurement': 'x', + }) +# --- +# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Human presence Office Sensitivity', + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'x', + }), + 'context': , + 'entity_id': 'number.human_presence_office_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- # name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_alarm_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0766edb9c4d0bf9a70cc0fe4ae0290949759145a Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Mon, 4 Aug 2025 12:15:38 +0300 Subject: [PATCH 0872/1118] Bump yt-dlp to 2025.07.21 (#149916) Co-authored-by: Joostlek --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 20068efccef..db622d21f1a 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.06.09"], + "requirements": ["yt-dlp[default]==2025.07.21"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 772a7372f31..52b2fdb42b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3185,7 +3185,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.06.09 +yt-dlp[default]==2025.07.21 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8744c9e3c8..ffed56c6f3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2632,7 +2632,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.06.09 +yt-dlp[default]==2025.07.21 # homeassistant.components.zamg zamg==0.3.6 From 5837f55205a4dc6979793c746838c7fd9d317091 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:23:58 +0200 Subject: [PATCH 0873/1118] Add extra Tuya snapshots for cz category (#149938) --- tests/components/tuya/__init__.py | 22 ++ .../tuya/fixtures/cz_0g1fmqh6d5io7lcn.json | 56 ++++ .../tuya/fixtures/cz_cuhokdii7ojyw8k2.json | 56 ++++ .../tuya/fixtures/cz_dntgh2ngvshfxpsz.json | 35 +++ .../tuya/fixtures/cz_hj0a5c7ckzzexu8l.json | 100 ++++++ .../tuya/fixtures/cz_t0a4hwsf8anfsadp.json | 118 +++++++ .../tuya/snapshots/test_select.ambr | 118 +++++++ .../tuya/snapshots/test_sensor.ambr | 174 +++++++++++ .../tuya/snapshots/test_switch.ambr | 293 ++++++++++++++++++ 9 files changed, 972 insertions(+) create mode 100644 tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json create mode 100644 tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json create mode 100644 tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json create mode 100644 tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json create mode 100644 tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 4a9d2acd772..f3525a6e173 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -70,11 +70,33 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cz_0g1fmqh6d5io7lcn": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SWITCH, + ], "cz_2jxesipczks0kdct": [ # https://github.com/home-assistant/core/issues/147149 Platform.SENSOR, Platform.SWITCH, ], + "cz_cuhokdii7ojyw8k2": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SWITCH, + ], + "cz_dntgh2ngvshfxpsz": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SWITCH, + ], + "cz_hj0a5c7ckzzexu8l": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SENSOR, + Platform.SWITCH, + ], + "cz_t0a4hwsf8anfsadp": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.SELECT, + Platform.SWITCH, + ], "dj_mki13ie507rlry4r": [ # https://github.com/home-assistant/core/pull/126242 Platform.LIGHT diff --git a/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json new file mode 100644 index 00000000000..760972e7fb0 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "01155072c4dd573f92b8", + "name": "Apollo light", + "category": "cz", + "product_id": "0g1fmqh6d5io7lcn", + "product_name": "Mini Smart Plug", + "online": false, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-16T17:19:42+00:00", + "create_time": "2024-06-16T17:19:42+00:00", + "update_time": "2024-06-16T17:19:42+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "秒", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "秒", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json new file mode 100644 index 00000000000..f259ebd7d6c --- /dev/null +++ b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "53703774d8f15ba9efd3", + "name": "Buitenverlichting", + "category": "cz", + "product_id": "cuhokdii7ojyw8k2", + "product_name": "Smart Plug-EU", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-09-02T12:52:48+00:00", + "create_time": "2021-09-02T12:52:48+00:00", + "update_time": "2021-09-02T12:52:48+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json new file mode 100644 index 00000000000..a92d2d370d0 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf7a2cdaf3ce28d2f7uqnh", + "name": "fakkel veranda ", + "category": "cz", + "product_id": "dntgh2ngvshfxpsz", + "product_name": "Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T07:59:28+00:00", + "create_time": "2023-01-18T07:59:28+00:00", + "update_time": "2023-01-18T07:59:28+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json new file mode 100644 index 00000000000..0638bb02d1e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "051724052462ab286504", + "name": "droger", + "category": "cz", + "product_id": "hj0a5c7ckzzexu8l", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-02-20T21:27:14+00:00", + "create_time": "2020-02-20T21:27:14+00:00", + "update_time": "2020-02-20T21:27:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 100, + "cur_current": 2754, + "cur_power": 5935, + "cur_voltage": 2224 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json new file mode 100644 index 00000000000..b7f913a7153 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json @@ -0,0 +1,118 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf4c0c538bfe408aa9gr2e", + "name": "wallwasher front", + "category": "cz", + "product_id": "t0a4hwsf8anfsadp", + "product_name": "Smart Plug ", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-12-11T15:51:53+00:00", + "create_time": "2022-12-11T15:51:53+00:00", + "update_time": "2022-12-11T15:51:53+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "relay_status": "power_on", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 3a30e8dceee..943e230b7cd 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -296,6 +296,124 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2elight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2erelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 91cc9e4739b..e8b9900185e 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -971,6 +971,180 @@ 'state': '121.7', }) # --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.051724052462ab286504cur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'droger Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.754', + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.051724052462ab286504cur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'droger Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.droger_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '593.5', + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.051724052462ab286504cur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'droger Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '222.4', + }) +# --- # name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 57a3fef5c6d..1b90c21bb46 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -434,6 +434,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cz_0g1fmqh6d5io7lcn][switch.apollo_light_socket_1-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.apollo_light_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.01155072c4dd573f92b8switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_0g1fmqh6d5io7lcn][switch.apollo_light_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Apollo light Socket 1', + }), + 'context': , + 'entity_id': 'switch.apollo_light_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -532,6 +581,250 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cz_cuhokdii7ojyw8k2][switch.buitenverlichting_socket_1-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.buitenverlichting_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.53703774d8f15ba9efd3switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_cuhokdii7ojyw8k2][switch.buitenverlichting_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Buitenverlichting Socket 1', + }), + 'context': , + 'entity_id': 'switch.buitenverlichting_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dntgh2ngvshfxpsz][switch.fakkel_veranda_socket_1-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.fakkel_veranda_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bf7a2cdaf3ce28d2f7uqnhswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_dntgh2ngvshfxpsz][switch.fakkel_veranda_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'fakkel veranda Socket 1', + }), + 'context': , + 'entity_id': 'switch.fakkel_veranda_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][switch.droger_socket_1-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.droger_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.051724052462ab286504switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][switch.droger_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'droger Socket 1', + }), + 'context': , + 'entity_id': 'switch.droger_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_child_lock-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': , + 'entity_id': 'switch.wallwasher_front_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2echild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Child lock', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_socket_1-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.wallwasher_front_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2eswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'wallwasher front Socket 1', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 377ca04be887a0a7928c61cb54d66be13905cb4c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:24:51 +0200 Subject: [PATCH 0874/1118] Update sensor icons in Volvo integration (#149811) --- homeassistant/components/volvo/icons.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 8e2897c66ad..61f67bcfe04 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -20,7 +20,11 @@ "default": "mdi:gas-station" }, "charger_connection_status": { - "default": "mdi:ev-plug-ccs2" + "default": "mdi:power-plug-off", + "state": { + "connected": "mdi:power-plug", + "fault": "mdi:flash-alert" + } }, "charging_power": { "default": "mdi:gauge-empty", @@ -44,22 +48,22 @@ } }, "distance_to_empty_battery": { - "default": "mdi:gauge-empty" + "default": "mdi:battery-outline" }, "distance_to_empty_tank": { "default": "mdi:gauge-empty" }, "distance_to_service": { - "default": "mdi:wrench-clock" + "default": "mdi:wrench-check" }, "engine_time_to_service": { - "default": "mdi:wrench-clock" + "default": "mdi:wrench-cog" }, "estimated_charging_time": { "default": "mdi:battery-clock" }, "fuel_amount": { - "default": "mdi:gas-station" + "default": "mdi:fuel" }, "odometer": { "default": "mdi:counter" From bd3fe1d4ad7c6536b5bdf5a55bb5489a48d27b5c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 4 Aug 2025 19:26:14 +1000 Subject: [PATCH 0875/1118] Fix credit sensor when there are no vehicles in Teslemetry (#149925) --- homeassistant/components/teslemetry/models.py | 2 +- homeassistant/components/teslemetry/sensor.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 51eed97227e..6d12aa56470 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -28,7 +28,7 @@ class TeslemetryData: vehicles: list[TeslemetryVehicleData] energysites: list[TeslemetryEnergyData] scopes: list[Scope] - stream: TeslemetryStream + stream: TeslemetryStream | None @dataclass diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 1ffe073cc5c..34ee2d4b8e9 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -45,7 +45,7 @@ from .entity import ( TeslemetryVehicleStreamEntity, TeslemetryWallConnectorEntity, ) -from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData +from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 @@ -1617,11 +1617,12 @@ async def async_setup_entry( if energysite.history_coordinator is not None ) - entities.append( - TeslemetryCreditBalanceSensor( - entry.unique_id or entry.entry_id, entry.runtime_data + if entry.runtime_data.stream is not None: + entities.append( + TeslemetryCreditBalanceSensor( + entry.unique_id or entry.entry_id, entry.runtime_data.stream + ) ) - ) async_add_entities(entities) @@ -1840,12 +1841,12 @@ class TeslemetryCreditBalanceSensor(RestoreSensor): _attr_state_class = SensorStateClass.MEASUREMENT _attr_suggested_display_precision = 0 - def __init__(self, uid: str, data: TeslemetryData) -> None: + def __init__(self, uid: str, stream: TeslemetryStream) -> None: """Initialize common aspects of a Teslemetry entity.""" self._attr_translation_key = "credit_balance" self._attr_unique_id = f"{uid}_credit_balance" - self.stream = data.stream + self.stream = stream async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" From cf14226b02df6bc133d49404ea40816a5184c38e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:02:21 +0200 Subject: [PATCH 0876/1118] Pass config entry to Fronius coordinator (#149954) --- homeassistant/components/fronius/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 8a3d1ebf04c..cfbdfbcb424 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -106,6 +106,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_logger_{self.host}", + config_entry=self.config_entry, ) await self.logger_coordinator.async_config_entry_first_refresh() @@ -120,6 +121,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_meters_{self.host}", + config_entry=self.config_entry, ) ) @@ -129,6 +131,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_ohmpilot_{self.host}", + config_entry=self.config_entry, ) ) @@ -138,6 +141,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_power_flow_{self.host}", + config_entry=self.config_entry, ) ) @@ -147,6 +151,7 @@ class FroniusSolarNet: solar_net=self, logger=_LOGGER, name=f"{DOMAIN}_storages_{self.host}", + config_entry=self.config_entry, ) ) @@ -206,6 +211,7 @@ class FroniusSolarNet: logger=_LOGGER, name=_inverter_name, inverter_info=_inverter_info, + config_entry=self.config_entry, ) if self.config_entry.state == ConfigEntryState.LOADED: await _coordinator.async_refresh() From fe2bd8d09e896fbead01306d0d8e36b31647c09f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:02:34 +0200 Subject: [PATCH 0877/1118] Add Tuya snapshots for ywcgq category (#149948) --- tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json | 139 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index f3525a6e173..4cbe270cdad 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -256,6 +256,10 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "ywcgq_wtzwyhkev3b4ubns": [ + # https://community.home-assistant.io/t/something-is-wrong-with-tuya-tank-level-sensors-with-the-new-official-integration/689321 + # not (yet) supported + ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json new file mode 100644 index 00000000000..f724ffe164f --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf27a4********368f4w", + "name": "Nivel del tanque A", + "category": "ywcgq", + "product_id": "wtzwyhkev3b4ubns", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-05T10:22:24+00:00", + "create_time": "2024-01-05T10:22:24+00:00", + "update_time": "2024-01-05T10:22:24+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 200, + "max": 2500, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2400, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 2, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 200, + "max": 2500, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2400, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "normal", + "liquid_depth": 77, + "max_set": 100, + "mini_set": 10, + "installation_height": 980, + "liquid_depth_max": 140, + "liquid_level_percent": 97 + }, + "set_up": false, + "support_local": true +} From f350a1a1fa8e44f8ee21ab1388b8c8ccca3d81d0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:03:39 +0200 Subject: [PATCH 0878/1118] Add hassfest check to help with future dependency updates (#149624) --- script/hassfest/requirements.py | 30 +++++++++++++++++-- tests/hassfest/test_requirements.py | 46 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 99a1c255e60..9b5334823b9 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -43,6 +43,13 @@ PACKAGE_CHECK_VERSION_RANGE = { "urllib3": "SemVer", "yarl": "SemVer", } +PACKAGE_CHECK_PREPARE_UPDATE: dict[str, int] = { + # In the form dict("dependencyX": n+1) + # - dependencyX should be the name of the referenced dependency + # - current major version +1 + # Pandas will only fully support Python 3.14 in v3. + "pandas": 3, +} PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # In the form dict("domain": {"package": {"dependency1", "dependency2"}}) # - domain is the integration domain @@ -53,6 +60,10 @@ PACKAGE_CHECK_VERSION_RANGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # geocachingapi > reverse_geocode > scipy > numpy "scipy": {"numpy"} }, + "noaa_tides": { + # https://github.com/GClunies/noaa_coops/pull/69 + "noaa-coops": {"pandas"} + }, } PACKAGE_REGEX = re.compile( @@ -568,7 +579,7 @@ def check_dependency_version_range( version == "Any" or (convention := PACKAGE_CHECK_VERSION_RANGE.get(pkg)) is None or all( - _is_dependency_version_range_valid(version_part, convention) + _is_dependency_version_range_valid(version_part, convention, pkg) for version_part in version.split(";", 1)[0].split(",") ) ): @@ -582,22 +593,35 @@ def check_dependency_version_range( return False -def _is_dependency_version_range_valid(version_part: str, convention: str) -> bool: +def _is_dependency_version_range_valid( + version_part: str, convention: str, pkg: str | None = None +) -> bool: + prepare_update = PACKAGE_CHECK_PREPARE_UPDATE.get(pkg) if pkg else None version_match = PIP_VERSION_RANGE_SEPARATOR.match(version_part.strip()) operator = version_match.group(1) version = version_match.group(2) + awesome = AwesomeVersion(version) if operator in (">", ">=", "!="): # Lower version binding and version exclusion are fine return True + if prepare_update is not None: + if operator in ("==", "~="): + # Only current major version allowed which prevents updates to the next one + return False + # Allow upper constraints for major version + 1 + if operator == "<" and awesome.section(0) < prepare_update + 1: + return False + if operator == "<=" and awesome.section(0) < prepare_update: + return False + if convention == "SemVer": if operator == "==": # Explicit version with wildcard is allowed only on major version # e.g. ==1.* is allowed, but ==1.2.* is not return version.endswith(".*") and version.count(".") == 1 - awesome = AwesomeVersion(version) if operator in ("<", "<="): # Upper version binding only allowed on major version # e.g. <=3 is allowed, but <=3.1 is not diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index b9259596c65..dcd35a3aca7 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -1,11 +1,17 @@ """Tests for hassfest requirements.""" from pathlib import Path +from unittest.mock import patch import pytest from script.hassfest.model import Config, Integration -from script.hassfest.requirements import validate_requirements_format +from script.hassfest.requirements import ( + PACKAGE_CHECK_PREPARE_UPDATE, + PACKAGE_CHECK_VERSION_RANGE, + check_dependency_version_range, + validate_requirements_format, +) @pytest.fixture @@ -105,3 +111,41 @@ def test_validate_requirements_format_github_custom(integration: Integration) -> integration.path = Path("") assert validate_requirements_format(integration) assert len(integration.errors) == 0 + + +@pytest.mark.parametrize( + ("version", "result"), + [ + (">2", True), + (">=2.0", True), + (">=2.0,<4", True), + ("<4", True), + ("<=3.0", True), + (">=2.0,<4;python_version<'3.14'", True), + ("<3", False), + ("==2.*", False), + ("~=2.0", False), + ("<=2.100", False), + (">2,<3", False), + (">=2.0,<3", False), + (">=2.0,<3;python_version<'3.14'", False), + ], +) +def test_dependency_version_range_prepare_update( + version: str, result: bool, integration: Integration +) -> None: + """Test dependency version range check for prepare update is working correctly.""" + with ( + patch.dict(PACKAGE_CHECK_VERSION_RANGE, {"numpy-test": "SemVer"}, clear=True), + patch.dict(PACKAGE_CHECK_PREPARE_UPDATE, {"numpy-test": 3}, clear=True), + ): + assert ( + check_dependency_version_range( + integration, + "test", + pkg="numpy-test", + version=version, + package_exceptions=set(), + ) + == result + ) From 8d8383e1c16d37c07f5c76f3fbb0bc23d4c4bb3e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:07:25 +0200 Subject: [PATCH 0879/1118] Add extra Tuya snapshots for dc and dj category (lights) (#149940) --- tests/components/tuya/__init__.py | 106 +- .../tuya/fixtures/dc_l3bpgg8ibsagon4x.json | 149 ++ .../tuya/fixtures/dj_8szt7whdvwpmxglk.json | 495 +++++ .../tuya/fixtures/dj_8y0aquaa8v6tho8w.json | 338 +++ .../tuya/fixtures/dj_baf9tt9lb8t5uc7z.json | 77 + .../tuya/fixtures/dj_d4g0fbsoaal841o6.json | 377 ++++ .../tuya/fixtures/dj_djnozmdyqyriow8z.json | 484 +++++ .../tuya/fixtures/dj_ekwolitfjhxn55js.json | 559 +++++ .../tuya/fixtures/dj_fuupmcr2mb1odkja.json | 338 +++ .../tuya/fixtures/dj_hp6orhaqm6as3jnv.json | 510 +++++ .../tuya/fixtures/dj_hpc8ddyfv85haxa7.json | 156 ++ .../tuya/fixtures/dj_iayz2jmtlipjnxj7.json | 529 +++++ .../tuya/fixtures/dj_idnfq7xbx8qewyoa.json | 523 +++++ .../tuya/fixtures/dj_ilddqqih3tucdk68.json | 77 + .../tuya/fixtures/dj_j1bgp31cffutizub.json | 434 ++++ .../tuya/fixtures/dj_lmnt3uyltk1xffrt.json | 77 + .../tuya/fixtures/dj_nbumqpv8vz61enji.json | 559 +++++ .../tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json | 77 + .../components/tuya/fixtures/dj_oe0cpnjg.json | 226 ++ .../components/tuya/fixtures/dj_riwp3k79.json | 402 ++++ .../tuya/fixtures/dj_tmsloaroqavbucgn.json | 377 ++++ .../tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json | 335 +++ .../tuya/fixtures/dj_vqwcnabamzrc2kab.json | 532 +++++ .../tuya/fixtures/dj_xokdfs6kh5ednakk.json | 377 ++++ .../tuya/fixtures/dj_zakhnlpdiu0ycdxn.json | 77 + .../tuya/fixtures/dj_zav1pa32pyxray78.json | 322 +++ .../tuya/fixtures/dj_zputiamzanuk6yky.json | 413 ++++ .../components/tuya/snapshots/test_light.ambr | 1903 +++++++++++++++++ 28 files changed, 10828 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json create mode 100644 tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json create mode 100644 tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json create mode 100644 tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json create mode 100644 tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json create mode 100644 tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json create mode 100644 tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json create mode 100644 tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json create mode 100644 tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json create mode 100644 tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json create mode 100644 tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json create mode 100644 tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json create mode 100644 tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json create mode 100644 tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json create mode 100644 tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json create mode 100644 tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json create mode 100644 tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json create mode 100644 tests/components/tuya/fixtures/dj_oe0cpnjg.json create mode 100644 tests/components/tuya/fixtures/dj_riwp3k79.json create mode 100644 tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json create mode 100644 tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json create mode 100644 tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json create mode 100644 tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json create mode 100644 tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json create mode 100644 tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json create mode 100644 tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 4cbe270cdad..7d6cd32959c 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -97,9 +97,113 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "dc_l3bpgg8ibsagon4x": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_8szt7whdvwpmxglk": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_8y0aquaa8v6tho8w": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_baf9tt9lb8t5uc7z": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_d4g0fbsoaal841o6": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_djnozmdyqyriow8z": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_ekwolitfjhxn55js": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_fuupmcr2mb1odkja": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_hp6orhaqm6as3jnv": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_hpc8ddyfv85haxa7": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_iayz2jmtlipjnxj7": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_idnfq7xbx8qewyoa": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_ilddqqih3tucdk68": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_j1bgp31cffutizub": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_lmnt3uyltk1xffrt": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], "dj_mki13ie507rlry4r": [ # https://github.com/home-assistant/core/pull/126242 - Platform.LIGHT + Platform.LIGHT, + ], + "dj_nbumqpv8vz61enji": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_nlxvjzy1hoeiqsg6": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_oe0cpnjg": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_riwp3k79": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_tmsloaroqavbucgn": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_ufq2xwuzd4nb0qdr": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_vqwcnabamzrc2kab": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_xokdfs6kh5ednakk": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_zakhnlpdiu0ycdxn": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_zav1pa32pyxray78": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, + ], + "dj_zputiamzanuk6yky": [ + # https://github.com/home-assistant/core/issues/149704 + Platform.LIGHT, ], "dlq_0tnvg2xaisqdadcf": [ # https://github.com/home-assistant/core/issues/102769 diff --git a/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json new file mode 100644 index 00000000000..b3759178618 --- /dev/null +++ b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json @@ -0,0 +1,149 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfd9f45c6b882c9f46dxfc", + "name": "LSC Party String Light RGBIC+CCT ", + "category": "dc", + "product_id": "l3bpgg8ibsagon4x", + "product_name": "LSC Party String Light RGBIC+CCT ", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-07-18T20:38:14+00:00", + "create_time": "2024-07-18T20:38:14+00:00", + "update_time": "2024-07-18T20:38:14+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value": 1000, + "temp_value": 0, + "colour_data": { + "h": 229, + "s": 1000, + "v": 1000 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json new file mode 100644 index 00000000000..6cd0ca55379 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json @@ -0,0 +1,495 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb10549aadfc74b7c8q2ti", + "name": "Porch light E", + "category": "dj", + "product_id": "8szt7whdvwpmxglk", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-06:00", + "active_time": "2024-06-19T00:38:29+00:00", + "create_time": "2024-06-19T00:38:29+00:00", + "update_time": "2024-06-19T00:38:29+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "colour_data_v2": { + "h": 245, + "s": 780, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json new file mode 100644 index 00000000000..ec8f6a0a4d5 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json @@ -0,0 +1,338 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf71858c3d27943679dsx9", + "name": "dressoir spot", + "category": "dj", + "product_id": "8y0aquaa8v6tho8w", + "product_name": "A60 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-18T07:49:40+00:00", + "create_time": "2023-01-18T07:49:40+00:00", + "update_time": "2023-01-18T07:49:40+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "remote_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json new file mode 100644 index 00000000000..211c0bc12cf --- /dev/null +++ b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "40611462e09806c73134", + "name": "Pokerlamp 2", + "category": "dj", + "product_id": "baf9tt9lb8t5uc7z", + "product_name": "LED SMART", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2021-10-30T17:22:29+00:00", + "create_time": "2021-10-30T17:22:29+00:00", + "update_time": "2021-10-30T17:22:29+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "bright_value": 45, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json new file mode 100644 index 00000000000..22650f7ae37 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json @@ -0,0 +1,377 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf671413db4cee1f9bqdcx", + "name": "WC D1", + "category": "dj", + "product_id": "d4g0fbsoaal841o6", + "product_name": "A60 GOLD", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-30T11:36:31+00:00", + "create_time": "2021-06-30T11:36:31+00:00", + "update_time": "2021-06-30T11:36:31+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json new file mode 100644 index 00000000000..67df13c674f --- /dev/null +++ b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json @@ -0,0 +1,484 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf8885f3d18a73e395bfac", + "name": "Fakkel 8", + "category": "dj", + "product_id": "djnozmdyqyriow8z", + "product_name": "Candle RGB-CCT", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-30T12:13:49+00:00", + "create_time": "2021-06-30T12:13:49+00:00", + "update_time": "2021-06-30T12:13:49+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 280, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 56, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json new file mode 100644 index 00000000000..90cad22fd09 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json @@ -0,0 +1,559 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb99bba00c9c90ba8gzgl", + "name": "ab6", + "category": "dj", + "product_id": "ekwolitfjhxn55js", + "product_name": "LSC Smart Connect GU10 RGB+CCT", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T21:26:33+00:00", + "create_time": "2024-10-30T21:26:33+00:00", + "update_time": "2024-10-30T21:26:33+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 3, + "s": 994, + "v": 443 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6AAA", + "do_not_disturb": false, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json new file mode 100644 index 00000000000..5b189b6a3e4 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json @@ -0,0 +1,338 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf0914a82b06ecf151xsf5", + "name": "Slaapkamer", + "category": "dj", + "product_id": "fuupmcr2mb1odkja", + "product_name": "ST64 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-28T01:25:04+00:00", + "create_time": "2023-01-28T01:25:04+00:00", + "update_time": "2023-01-28T01:25:04+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json new file mode 100644 index 00000000000..e8166a192dc --- /dev/null +++ b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json @@ -0,0 +1,510 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "00450321483fda81c529", + "name": "Master bedroom TV lights", + "category": "dj", + "product_id": "hp6orhaqm6as3jnv", + "product_name": "LED Strip Lights", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-19T03:35:54+00:00", + "create_time": "2024-06-19T03:35:54+00:00", + "update_time": "2024-06-19T03:35:54+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value": 96, + "temp_value": 223, + "colour_data": { + "h": 27.0, + "s": 255.0, + "v": 52.0 + }, + "scene_data": { + "h": 16.0, + "s": 255.0, + "v": 210.9 + }, + "flash_scene_1": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_2": { + "bright": 255, + "frequency": 128, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + }, + "flash_scene_3": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_4": { + "bright": 255, + "frequency": 5, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 60.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 300.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json new file mode 100644 index 00000000000..893aafa3759 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json @@ -0,0 +1,156 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "63362034840d8eb9029f", + "name": "Garage", + "category": "dj", + "product_id": "hpc8ddyfv85haxa7", + "product_name": "RGB Smart Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-12-21T14:43:57+00:00", + "create_time": "2020-12-21T14:43:57+00:00", + "update_time": "2020-12-21T14:43:57+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 255, + "temp_value": 255, + "colour_data_v2": { + "h": 16384, + "s": 65280, + "v": 65535 + }, + "switch_1": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json new file mode 100644 index 00000000000..f9062d9146d --- /dev/null +++ b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json @@ -0,0 +1,529 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf0fc1d7d4caa71a59us7c", + "name": "LED Porch 2", + "category": "dj", + "product_id": "iayz2jmtlipjnxj7", + "product_name": "LED Strip RGB+W", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-07T10:55:19+00:00", + "create_time": "2021-06-07T10:55:19+00:00", + "update_time": "2021-06-07T10:55:19+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 839, + "colour_data_v2": { + "h": 13, + "s": 992, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json new file mode 100644 index 00000000000..295157d8370 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json @@ -0,0 +1,523 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf599f5cffe1a5985depyk", + "name": "AB1", + "category": "dj", + "product_id": "idnfq7xbx8qewyoa", + "product_name": "Smart Lamp", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-08-16T12:51:52+00:00", + "create_time": "2021-08-16T12:51:52+00:00", + "update_time": "2021-08-16T12:51:52+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "scene", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 6, + "s": 978, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 6, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "jump", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json new file mode 100644 index 00000000000..1181b650f3e --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "84178216d8f15be52dc4", + "name": "Ieskas", + "category": "dj", + "product_id": "ilddqqih3tucdk68", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:07:13+00:00", + "create_time": "2025-05-28T20:07:13+00:00", + "update_time": "2025-05-28T20:07:13+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "bright_value": 255, + "temp_value": 158 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json new file mode 100644 index 00000000000..d95179c921f --- /dev/null +++ b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json @@ -0,0 +1,434 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfe49d7b6cd80536efdldi", + "name": "Ceiling Portal", + "category": "dj", + "product_id": "j1bgp31cffutizub", + "product_name": "LSC Smart Ceiling Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-31T12:27:35+00:00", + "create_time": "2022-01-31T12:27:35+00:00", + "update_time": "2022-01-31T12:27:35+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 950, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json new file mode 100644 index 00000000000..93a802a7ee3 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "07608286600194e94248", + "name": "DirectietKamer", + "category": "dj", + "product_id": "lmnt3uyltk1xffrt", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:00:48+00:00", + "create_time": "2025-05-28T20:00:48+00:00", + "update_time": "2025-05-28T20:00:48+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 255, + "temp_value": 255 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json new file mode 100644 index 00000000000..bc919dd92d2 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json @@ -0,0 +1,559 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf77c04cbd6a52a7be16ll", + "name": "b2", + "category": "dj", + "product_id": "nbumqpv8vz61enji", + "product_name": "LSC smart GU10", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T21:35:27+00:00", + "create_time": "2024-10-30T21:35:27+00:00", + "update_time": "2024-10-30T21:35:27+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 10, + "temp_value_v2": 150, + "colour_data_v2": { + "h": 119, + "s": 935, + "v": 132 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6ACW", + "do_not_disturb": true, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json new file mode 100644 index 00000000000..c519f1aa593 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "40350105dc4f229a464e", + "name": "hall 💡 ", + "category": "dj", + "product_id": "nlxvjzy1hoeiqsg6", + "product_name": "LED SMART", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2020-06-23T21:37:40+00:00", + "create_time": "2020-06-23T21:37:40+00:00", + "update_time": "2020-06-23T21:37:40+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 135, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_oe0cpnjg.json b/tests/components/tuya/fixtures/dj_oe0cpnjg.json new file mode 100644 index 00000000000..646ce8a93d7 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_oe0cpnjg.json @@ -0,0 +1,226 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf8d8af3ddfe75b0195r0h", + "name": "Front right Lighting trap", + "category": "dj", + "product_id": "oe0cpnjg", + "product_name": "Smart Lighting", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-10-03T13:23:20+00:00", + "create_time": "2023-10-03T13:23:20+00:00", + "update_time": "2023-10-03T13:23:20+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 985, + "colour_data_v2": "", + "music_data": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_riwp3k79.json b/tests/components/tuya/fixtures/dj_riwp3k79.json new file mode 100644 index 00000000000..f1a3579e660 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_riwp3k79.json @@ -0,0 +1,402 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf46b2b81ca41ce0c1xpsw", + "name": "LED KEUKEN 2", + "category": "dj", + "product_id": "riwp3k79", + "product_name": "atmosphere", + "online": true, + "sub": true, + "time_zone": "+08:00", + "active_time": "2020-12-29T16:16:11+00:00", + "create_time": "2020-12-29T16:16:11+00:00", + "update_time": "2020-12-29T16:16:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 27, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 6, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 100, + "unit_switch_duration": 100, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 100, + "unit_switch_duration": 100, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json new file mode 100644 index 00000000000..20c91ad7739 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json @@ -0,0 +1,377 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf252b8ee16b2e78bdoxlp", + "name": "Pokerlamp 1", + "category": "dj", + "product_id": "tmsloaroqavbucgn", + "product_name": "G95-Filament", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-06-29T16:12:54+00:00", + "create_time": "2021-06-29T16:12:54+00:00", + "update_time": "2021-06-29T16:12:54+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 400, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json new file mode 100644 index 00000000000..7ea5905411d --- /dev/null +++ b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json @@ -0,0 +1,335 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf8edbd51a52c01a4bfgqf", + "name": "Sjiethoes", + "category": "dj", + "product_id": "ufq2xwuzd4nb0qdr", + "product_name": "Smart Ceiling Lamp", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-29T09:40:53+00:00", + "create_time": "2025-04-29T09:40:53+00:00", + "update_time": "2025-04-29T09:40:53+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 46, + "s": 1000, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json new file mode 100644 index 00000000000..4d6749ea0b4 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json @@ -0,0 +1,532 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfd56f4718874ee8830xdw", + "name": "Strip 2", + "category": "dj", + "product_id": "vqwcnabamzrc2kab", + "product_name": "Light Strip-RGBCW ", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2021-10-22T13:55:55+00:00", + "create_time": "2021-10-22T13:55:55+00:00", + "update_time": "2021-10-22T13:55:55+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 218, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 8, + "scene_units": [ + { + "bright": 0, + "h": 0, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 120, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 240, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 61, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 174, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + }, + { + "bright": 0, + "h": 275, + "s": 1000, + "temperature": 0, + "unit_change_mode": "gradient", + "unit_gradient_duration": 70, + "unit_switch_duration": 70, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json new file mode 100644 index 00000000000..cce66d90b0c --- /dev/null +++ b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json @@ -0,0 +1,377 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfc1ef4da4accc0731oggw", + "name": "ERKER 1-Gold ", + "category": "dj", + "product_id": "xokdfs6kh5ednakk", + "product_name": "LSC-G125-Gold ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-01-30T22:02:31+00:00", + "create_time": "2022-01-30T22:02:31+00:00", + "update_time": "2022-01-30T22:02:31+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "remote_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "remote_switch": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json new file mode 100644 index 00000000000..d1c23663144 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "03010850c44f33966362", + "name": "Stoel", + "category": "dj", + "product_id": "zakhnlpdiu0ycdxn", + "product_name": "LED SMART", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-10T18:55:12+00:00", + "create_time": "2023-08-10T18:55:12+00:00", + "update_time": "2023-08-10T18:55:12+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 71, + "temp_value": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json new file mode 100644 index 00000000000..624f7fb4347 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json @@ -0,0 +1,322 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "500425642462ab50909b", + "name": "Gengske 💡 ", + "category": "dj", + "product_id": "zav1pa32pyxray78", + "product_name": "Ceiling Light RGBTW", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-28T20:00:48+00:00", + "create_time": "2025-05-28T20:00:48+00:00", + "update_time": "2025-05-28T20:00:48+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "temp_value_v2": 380, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 102 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json new file mode 100644 index 00000000000..cede2b65682 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json @@ -0,0 +1,413 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf74164049de868395pbci", + "name": "Floodlight", + "category": "dj", + "product_id": "zputiamzanuk6yky", + "product_name": "LSC Floodlight", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-09T08:14:06+00:00", + "create_time": "2025-06-09T08:14:06+00:00", + "update_time": "2025-06-09T08:14:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value_v2": 1000, + "colour_data_v2": { + "h": 295, + "s": 920, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAGwG/A+gD6APo", + "do_not_disturb": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 26648b0f2cb..b5dca58f8e7 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,6 +56,1141 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dc_l3bpgg8ibsagon4x][light.lsc_party_string_light_rgbic_cct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.lsc_party_string_light_rgbic_cct', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfd9f45c6b882c9f46dxfcswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dc_l3bpgg8ibsagon4x][light.lsc_party_string_light_rgbic_cct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LSC Party String Light RGBIC+CCT ', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lsc_party_string_light_rgbic_cct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_8szt7whdvwpmxglk][light.porch_light_e-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.porch_light_e', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb10549aadfc74b7c8q2tiswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_8szt7whdvwpmxglk][light.porch_light_e-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Porch light E', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.porch_light_e', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_8y0aquaa8v6tho8w][light.dressoir_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.dressoir_spot', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf71858c3d27943679dsx9switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_8y0aquaa8v6tho8w][light.dressoir_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'dressoir spot', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.dressoir_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_baf9tt9lb8t5uc7z][light.pokerlamp_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.pokerlamp_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.40611462e09806c73134switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_baf9tt9lb8t5uc7z][light.pokerlamp_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_d4g0fbsoaal841o6][light.wc_d1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.wc_d1', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf671413db4cee1f9bqdcxswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_d4g0fbsoaal841o6][light.wc_d1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WC D1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.wc_d1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_djnozmdyqyriow8z][light.fakkel_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.fakkel_8', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf8885f3d18a73e395bfacswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_djnozmdyqyriow8z][light.fakkel_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 70, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Fakkel 8', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.fakkel_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_ekwolitfjhxn55js][light.ab6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ab6', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfb99bba00c9c90ba8gzglswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_ekwolitfjhxn55js][light.ab6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ab6', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.ab6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_fuupmcr2mb1odkja][light.slaapkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.slaapkamer', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf0914a82b06ecf151xsf5switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_fuupmcr2mb1odkja][light.slaapkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Slaapkamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.slaapkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_hp6orhaqm6as3jnv][light.master_bedroom_tv_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.master_bedroom_tv_lights', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.00450321483fda81c529switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_hp6orhaqm6as3jnv][light.master_bedroom_tv_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Master bedroom TV lights', + 'hs_color': tuple( + 26.072, + 100.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 111, + 0, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.632, + 0.358, + ), + }), + 'context': , + 'entity_id': 'light.master_bedroom_tv_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.63362034840d8eb9029fswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Garage', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage_light_1-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.garage_light_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': 'Light 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_light', + 'unique_id': 'tuya.63362034840d8eb9029fswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Garage Light 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.garage_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_iayz2jmtlipjnxj7][light.led_porch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.led_porch_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf0fc1d7d4caa71a59us7cswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_iayz2jmtlipjnxj7][light.led_porch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LED Porch 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.led_porch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_idnfq7xbx8qewyoa][light.ab1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ab1', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf599f5cffe1a5985depykswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_idnfq7xbx8qewyoa][light.ab1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'AB1', + 'hs_color': tuple( + 6.0, + 97.8, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 31, + 6, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.693, + 0.304, + ), + }), + 'context': , + 'entity_id': 'light.ab1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_ilddqqih3tucdk68][light.ieskas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ieskas', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.84178216d8f15be52dc4switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_ilddqqih3tucdk68][light.ieskas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 285, + 'color_temp_kelvin': 3508, + 'friendly_name': 'Ieskas', + 'hs_color': tuple( + 27.165, + 44.6, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 193, + 141, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.453, + 0.374, + ), + }), + 'context': , + 'entity_id': 'light.ieskas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_j1bgp31cffutizub][light.ceiling_portal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ceiling_portal', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfe49d7b6cd80536efdldiswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_j1bgp31cffutizub][light.ceiling_portal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Ceiling Portal', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.ceiling_portal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_lmnt3uyltk1xffrt][light.directietkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.directietkamer', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.07608286600194e94248switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_lmnt3uyltk1xffrt][light.directietkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'DirectietKamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.directietkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[dj_mki13ie507rlry4r][light.garage_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -119,6 +1254,774 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dj_nbumqpv8vz61enji][light.b2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.b2', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf77c04cbd6a52a7be16llswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_nbumqpv8vz61enji][light.b2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'b2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.b2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_nlxvjzy1hoeiqsg6][light.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.hall', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.40350105dc4f229a464eswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_nlxvjzy1hoeiqsg6][light.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'hall 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_oe0cpnjg][light.front_right_lighting_trap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.front_right_lighting_trap', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf8d8af3ddfe75b0195r0hswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_oe0cpnjg][light.front_right_lighting_trap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Front right Lighting trap', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.front_right_lighting_trap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_riwp3k79][light.led_keuken_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.led_keuken_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf46b2b81ca41ce0c1xpswswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_riwp3k79][light.led_keuken_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'LED KEUKEN 2', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.led_keuken_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_tmsloaroqavbucgn][light.pokerlamp_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.pokerlamp_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf252b8ee16b2e78bdoxlpswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_tmsloaroqavbucgn][light.pokerlamp_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_ufq2xwuzd4nb0qdr][light.sjiethoes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.sjiethoes', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf8edbd51a52c01a4bfgqfswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_ufq2xwuzd4nb0qdr][light.sjiethoes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sjiethoes', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.sjiethoes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_vqwcnabamzrc2kab][light.strip_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.strip_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfd56f4718874ee8830xdwswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_vqwcnabamzrc2kab][light.strip_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Strip 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.strip_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_xokdfs6kh5ednakk][light.erker_1_gold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.erker_1_gold', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bfc1ef4da4accc0731oggwswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_xokdfs6kh5ednakk][light.erker_1_gold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'ERKER 1-Gold ', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.erker_1_gold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[dj_zakhnlpdiu0ycdxn][light.stoel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.stoel', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.03010850c44f33966362switch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_zakhnlpdiu0ycdxn][light.stoel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stoel', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.stoel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[dj_zav1pa32pyxray78][light.gengske-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.gengske', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.500425642462ab50909bswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_zav1pa32pyxray78][light.gengske-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Gengske 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.gengske', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dj_zputiamzanuk6yky][light.floodlight-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.floodlight', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf74164049de868395pbciswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_zputiamzanuk6yky][light.floodlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Floodlight', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.floodlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c1ccfee7cc79f9ef8b6731d006c481253a5820b2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:08:03 +0200 Subject: [PATCH 0880/1118] Pass config entry to AsusWRT coordinator (#149953) --- homeassistant/components/asuswrt/router.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index a34f191b7a7..3cf8d2e863d 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pyasuswrt import AsusWrtError @@ -40,6 +40,9 @@ from .const import ( SENSORS_CONNECTED_DEVICE, ) +if TYPE_CHECKING: + from . import AsusWrtConfigEntry + CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] SCAN_INTERVAL = timedelta(seconds=30) @@ -52,10 +55,13 @@ _LOGGER = logging.getLogger(__name__) class AsusWrtSensorDataHandler: """Data handler for AsusWrt sensor.""" - def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None: + def __init__( + self, hass: HomeAssistant, api: AsusWrtBridge, entry: AsusWrtConfigEntry + ) -> None: """Initialize a AsusWrt sensor data handler.""" self._hass = hass self._api = api + self._entry = entry self._connected_devices = 0 async def _get_connected_devices(self) -> dict[str, int]: @@ -91,6 +97,7 @@ class AsusWrtSensorDataHandler: update_method=method, # Polling interval. Will only be polled if there are subscribers. update_interval=SCAN_INTERVAL if should_poll else None, + config_entry=self._entry, ) await coordinator.async_refresh() @@ -321,7 +328,9 @@ class AsusWrtRouter: if self._sensors_data_handler: return - self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api) + self._sensors_data_handler = AsusWrtSensorDataHandler( + self.hass, self._api, self._entry + ) self._sensors_data_handler.update_device_count(self._connected_devices) sensors_types = await self._api.async_get_available_sensors() From afffe0b08bf450494f242e709529ac052ad19fce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Aug 2025 12:20:30 +0200 Subject: [PATCH 0881/1118] Fix DeviceEntry.suggested_area deprecation warning (#149951) --- homeassistant/helpers/device_registry.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 72d0cf651f2..c7f7d4c369d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1221,8 +1221,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("name_by_user", name_by_user), ("serial_number", serial_number), - # Can be removed when suggested_area is removed from DeviceEntry - ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device_id", via_device_id), ): @@ -1230,6 +1228,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values[attr_name] = value old_values[attr_name] = getattr(old, attr_name) + # Can be removed when suggested_area is removed from DeviceEntry + if suggested_area is not UNDEFINED and suggested_area != old._suggested_area: # noqa: SLF001 + new_values["suggested_area"] = suggested_area + old_values["suggested_area"] = old._suggested_area # noqa: SLF001 + if old.is_new: new_values["is_new"] = False From cbf4130bff17d746d72b8e307e195767fec40fbd Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Mon, 4 Aug 2025 12:26:22 +0200 Subject: [PATCH 0882/1118] Add zeroconf flow to Homee (#149820) Co-authored-by: Joostlek --- homeassistant/components/homee/config_flow.py | 157 ++++++++--- homeassistant/components/homee/const.py | 5 + homeassistant/components/homee/manifest.json | 8 +- homeassistant/components/homee/strings.json | 11 + homeassistant/generated/zeroconf.py | 4 + tests/components/homee/test_config_flow.py | 248 ++++++++++++++---- 6 files changed, 343 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 7030752f4c3..44c9b70953b 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -11,10 +11,16 @@ from pyHomee import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DOMAIN +from .const import ( + DOMAIN, + RESULT_CANNOT_CONNECT, + RESULT_INVALID_AUTH, + RESULT_UNKNOWN_ERROR, +) _LOGGER = logging.getLogger(__name__) @@ -33,60 +39,137 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 homee: Homee + _host: str + _name: str _reauth_host: str _reauth_username: str + async def _connect_homee(self) -> dict[str, str]: + errors: dict[str, str] = {} + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = RESULT_CANNOT_CONNECT + except HomeeAuthenticationFailedException: + errors["base"] = RESULT_INVALID_AUTH + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = RESULT_UNKNOWN_ERROR + else: + _LOGGER.info("Got access token for homee") + self.hass.loop.create_task(self.homee.run()) + _LOGGER.debug("Homee task created") + await self.homee.wait_until_connected() + _LOGGER.info("Homee connected") + self.homee.disconnect() + _LOGGER.debug("Homee disconnecting") + await self.homee.wait_until_disconnected() + _LOGGER.info("Homee config successfully tested") + + await self.async_set_unique_id( + self.homee.settings.uid, raise_on_progress=self.source != SOURCE_USER + ) + + self._abort_if_unique_id_configured() + + _LOGGER.info("Created new homee entry with ID %s", self.homee.settings.uid) + + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial user step.""" + errors: dict[str, str] = {} - errors = {} if user_input is not None: self.homee = Homee( user_input[CONF_HOST], user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) + errors = await self._connect_homee() - try: - await self.homee.get_access_token() - except HomeeConnectionFailedException: - errors["base"] = "cannot_connect" - except HomeeAuthenticationFailedException: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - _LOGGER.info("Got access token for homee") - self.hass.loop.create_task(self.homee.run()) - _LOGGER.debug("Homee task created") - await self.homee.wait_until_connected() - _LOGGER.info("Homee connected") - self.homee.disconnect() - _LOGGER.debug("Homee disconnecting") - await self.homee.wait_until_disconnected() - _LOGGER.info("Homee config successfully tested") - - await self.async_set_unique_id(self.homee.settings.uid) - - self._abort_if_unique_id_configured() - - _LOGGER.info( - "Created new homee entry with ID %s", self.homee.settings.uid - ) - + if not errors: return self.async_create_entry( title=f"{self.homee.settings.homee_name} ({self.homee.host})", data=user_input, ) + return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, errors=errors, ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # Ensure that an IPv4 address is received + self._host = discovery_info.host + self._name = discovery_info.hostname[6:18] + if discovery_info.ip_address.version == 6: + return self.async_abort(reason="ipv6_address") + + await self.async_set_unique_id(self._name) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + # Cause an auth-error to see if homee is reachable. + self.homee = Homee( + self._host, + "dummy_username", + "dummy_password", + ) + errors = await self._connect_homee() + if errors["base"] != RESULT_INVALID_AUTH: + return self.async_abort(reason=RESULT_CANNOT_CONNECT) + + self.context["title_placeholders"] = {"name": self._name, "host": self._host} + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the configuration of the device.""" + + errors: dict[str, str] = {} + if user_input is not None: + self.homee = Homee( + self._host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + errors = await self._connect_homee() + + if not errors: + return self.async_create_entry( + title=f"{self.homee.settings.homee_name} ({self.homee.host})", + data={ + CONF_HOST: self._host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders={ + CONF_HOST: self._name, + }, + last_step=True, + ) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -108,12 +191,12 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): try: await self.homee.get_access_token() except HomeeConnectionFailedException: - errors["base"] = "cannot_connect" + errors["base"] = RESULT_CANNOT_CONNECT except HomeeAuthenticationFailedException: - errors["base"] = "invalid_auth" + errors["base"] = RESULT_INVALID_AUTH except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors["base"] = RESULT_UNKNOWN_ERROR else: self.hass.loop.create_task(self.homee.run()) await self.homee.wait_until_connected() @@ -161,12 +244,12 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): try: await self.homee.get_access_token() except HomeeConnectionFailedException: - errors["base"] = "cannot_connect" + errors["base"] = RESULT_CANNOT_CONNECT except HomeeAuthenticationFailedException: - errors["base"] = "invalid_auth" + errors["base"] = RESULT_INVALID_AUTH except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors["base"] = RESULT_UNKNOWN_ERROR else: self.hass.loop.create_task(self.homee.run()) await self.homee.wait_until_connected() diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 7bc3de189d6..718baf346ae 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -20,6 +20,11 @@ from homeassistant.const import ( # General DOMAIN = "homee" +# Error strings +RESULT_CANNOT_CONNECT = "cannot_connect" +RESULT_INVALID_AUTH = "invalid_auth" +RESULT_UNKNOWN_ERROR = "unknown" + # Sensor mappings HOMEE_UNIT_TO_HA_UNIT = { "": None, diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 9cac876f325..35e89ec645a 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,11 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "silver", - "requirements": ["pyHomee==1.2.10"] + "requirements": ["pyHomee==1.2.10"], + "zeroconf": [ + { + "type": "_ssh._tcp.local.", + "name": "homee-*" + } + ] } diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 267d5553a8c..26fa335d147 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -46,6 +46,17 @@ "data_description": { "host": "[%key:component::homee::config::step::user::data_description::host%]" } + }, + "zeroconf_confirm": { + "title": "Configure discovered homee {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::homee::config::step::user::data_description::username%]", + "password": "[%key:component::homee::config::step::user::data_description::password%]" + } } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a3668acee8d..742840fa849 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -890,6 +890,10 @@ ZEROCONF = { }, ], "_ssh._tcp.local.": [ + { + "domain": "homee", + "name": "homee-*", + }, { "domain": "smappee", "name": "smappee1*", diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py index 6f45dcbdb0d..3d2195443a2 100644 --- a/tests/components/homee/test_config_flow.py +++ b/tests/components/homee/test_config_flow.py @@ -1,15 +1,23 @@ """Test the Homee config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException import pytest -from homeassistant.components.homee.const import DOMAIN +from homeassistant import config_entries +from homeassistant.components.homee.const import ( + DOMAIN, + RESULT_CANNOT_CONNECT, + RESULT_INVALID_AUTH, + RESULT_UNKNOWN_ERROR, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .conftest import ( HOMEE_ID, @@ -24,6 +32,24 @@ from .conftest import ( from tests.common import MockConfigEntry +PARAMETRIZED_ERRORS = ( + ("side_eff", "error"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + {"base": RESULT_CANNOT_CONNECT}, + ), + ( + HomeeAuthFailedException("wrong username or password"), + {"base": RESULT_INVALID_AUTH}, + ), + ( + Exception, + {"base": RESULT_UNKNOWN_ERROR}, + ), + ], +) + @pytest.mark.usefixtures("mock_homee", "mock_config_entry", "mock_setup_entry") async def test_config_flow( @@ -58,23 +84,7 @@ async def test_config_flow( assert result["result"].unique_id == HOMEE_ID -@pytest.mark.parametrize( - ("side_eff", "error"), - [ - ( - HomeeConnectionFailedException("connection timed out"), - {"base": "cannot_connect"}, - ), - ( - HomeeAuthFailedException("wrong username or password"), - {"base": "invalid_auth"}, - ), - ( - Exception, - {"base": "unknown"}, - ), - ], -) +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) async def test_config_flow_errors( hass: HomeAssistant, mock_homee: AsyncMock, @@ -140,6 +150,172 @@ async def test_flow_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_homee", "mock_config_entry") +async def test_zeroconf_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_homee: AsyncMock, +) -> None: + """Test zeroconf discovery flow.""" + mock_homee.get_access_token.side_effect = HomeeAuthFailedException( + "wrong username or password" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["handler"] == DOMAIN + mock_setup_entry.assert_not_called() + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["data"] == { + CONF_HOST: HOMEE_IP, + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + } + + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) +async def test_zeroconf_confirm_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test zeroconf discovery flow errors.""" + mock_homee.get_access_token.side_effect = HomeeAuthFailedException( + "wrong username or password" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + flow_id = result["flow_id"] + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["handler"] == DOMAIN + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == error + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_USERNAME: TESTUSER, + CONF_PASSWORD: TESTPASS, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_zeroconf_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery flow when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(HOMEE_IP), + ip_addresses=[ip_address(HOMEE_IP)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_eff", "ip", "reason"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + HOMEE_IP, + RESULT_CANNOT_CONNECT, + ), + (Exception, HOMEE_IP, RESULT_CANNOT_CONNECT), + (None, "2001:db8::1", "ipv6_address"), + ], +) +async def test_zeroconf_errors( + hass: HomeAssistant, + mock_homee: AsyncMock, + side_eff: Exception, + ip: str, + reason: str, +) -> None: + """Test zeroconf discovery flow with an IPv6 address.""" + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + name=f"homee-{HOMEE_ID}._ssh._tcp.local.", + type="_ssh._tcp.local.", + hostname=f"homee-{HOMEE_ID}.local.", + ip_address=ip_address(ip), + ip_addresses=[ip_address(ip)], + port=22, + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + @pytest.mark.usefixtures("mock_homee", "mock_setup_entry") async def test_reauth_success( hass: HomeAssistant, @@ -171,23 +347,7 @@ async def test_reauth_success( assert mock_config_entry.data[CONF_PASSWORD] == NEW_TESTPASS -@pytest.mark.parametrize( - ("side_eff", "error"), - [ - ( - HomeeConnectionFailedException("connection timed out"), - {"base": "cannot_connect"}, - ), - ( - HomeeAuthFailedException("wrong username or password"), - {"base": "invalid_auth"}, - ), - ( - Exception, - {"base": "unknown"}, - ), - ], -) +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) async def test_reauth_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -296,23 +456,7 @@ async def test_reconfigure_success( assert mock_config_entry.data[CONF_PASSWORD] == TESTPASS -@pytest.mark.parametrize( - ("side_eff", "error"), - [ - ( - HomeeConnectionFailedException("connection timed out"), - {"base": "cannot_connect"}, - ), - ( - HomeeAuthFailedException("wrong username or password"), - {"base": "invalid_auth"}, - ), - ( - Exception, - {"base": "unknown"}, - ), - ], -) +@pytest.mark.parametrize(*PARAMETRIZED_ERRORS) async def test_reconfigure_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 106c086e8bc99eb6a504bd53421a484b2b5da761 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:29:27 +0200 Subject: [PATCH 0883/1118] Pass config entry to Unifi coordinator (#149952) --- homeassistant/components/unifi/hub/entity_loader.py | 8 +++++--- homeassistant/components/unifi/hub/hub.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 84948a92e98..4fd3d34a51d 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -25,6 +25,7 @@ from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS from ..entity import UnifiEntity, UnifiEntityDescription if TYPE_CHECKING: + from .. import UnifiConfigEntry from .hub import UnifiHub CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) @@ -34,7 +35,7 @@ POLL_INTERVAL = timedelta(seconds=10) class UnifiEntityLoader: """UniFi Network integration handling platforms for entity registration.""" - def __init__(self, hub: UnifiHub) -> None: + def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: """Initialize the UniFi entity loader.""" self.hub = hub self.api_updaters = ( @@ -57,15 +58,16 @@ class UnifiEntityLoader: ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] - self._dataUpdateCoordinator = DataUpdateCoordinator( + self._data_update_coordinator = DataUpdateCoordinator( hub.hass, LOGGER, name="Unifi entity poller", + config_entry=config_entry, update_method=self._update_pollable_api_data, update_interval=POLL_INTERVAL, ) - self._update_listener = self._dataUpdateCoordinator.async_add_listener( + self._update_listener = self._data_update_coordinator.async_add_listener( update_callback=lambda: None ) diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index 6cf8825a26c..9ea887bdb29 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -39,7 +39,7 @@ class UnifiHub: self.hass = hass self.api = api self.config = UnifiConfig.from_config_entry(config_entry) - self.entity_loader = UnifiEntityLoader(self) + self.entity_loader = UnifiEntityLoader(self, config_entry) self._entity_helper = UnifiEntityHelper(hass, api) self.websocket = UnifiWebsocket(hass, api, self.signal_reachable) From c2b298283e5f8b30198c775a70b9e4ee0e3e8108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Aug 2025 11:32:01 +0100 Subject: [PATCH 0884/1118] Bump hass-nabucasa from 0.110.0 to 0.110.1 (#149956) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index a819203e549..63eae6261d4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.110.0"], + "requirements": ["hass-nabucasa==0.110.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 80354c706d6..f8a57ba61bb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.110.0 +hass-nabucasa==0.110.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250731.0 diff --git a/pyproject.toml b/pyproject.toml index 2bcb7787601..a32e9308fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.110.0", + "hass-nabucasa==0.110.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index a332eb930c2..ba08a72e324 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.110.0 +hass-nabucasa==0.110.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 52b2fdb42b8..8dcc7478d7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.0 +hass-nabucasa==0.110.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffed56c6f3e..ce01dbd5a51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.0 +hass-nabucasa==0.110.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 93e11aa8bc7dcda4e1b9ae467540908b719b2d6b Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:35:24 +0200 Subject: [PATCH 0885/1118] Refresh plugwise test-fixtures (#149875) --- tests/components/plugwise/fixtures/legacy_anna/data.json | 2 ++ .../fixtures/m_adam_multiple_devices_per_zone/data.json | 3 ++- tests/components/plugwise/snapshots/test_diagnostics.ambr | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json index cc7e66fb174..75c12a4c8c2 100644 --- a/tests/components/plugwise/fixtures/legacy_anna/data.json +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -35,6 +35,7 @@ }, "0d266432d64443e283b5d708ae98b455": { "active_preset": "home", + "available_schedules": [], "climate_mode": "heat", "control_state": "heating", "dev_class": "thermostat", @@ -44,6 +45,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "select_schedule": null, "sensors": { "illuminance": 150.8, "setpoint": 20.5, diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json index 06459a11798..f1880ba69e1 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -112,12 +112,14 @@ }, "446ac08dd04d4eff8ac57489757b7314": { "active_preset": "no_frost", + "available_schedules": [], "climate_mode": "heat", "control_state": "idle", "dev_class": "climate", "model": "ThermoZone", "name": "Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": null, "sensors": { "temperature": 15.6 }, @@ -587,7 +589,6 @@ "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." } }, - "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": 7.81 }, diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 4aa367bc116..91411c323ac 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -131,6 +131,8 @@ }), '446ac08dd04d4eff8ac57489757b7314': dict({ 'active_preset': 'no_frost', + 'available_schedules': list([ + ]), 'climate_mode': 'heat', 'control_state': 'idle', 'dev_class': 'climate', @@ -143,6 +145,7 @@ 'vacation', 'no_frost', ]), + 'select_schedule': None, 'sensors': dict({ 'temperature': 15.6, }), @@ -635,7 +638,6 @@ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", }), }), - 'select_regulation_mode': 'heating', 'sensors': dict({ 'outdoor_temperature': 7.81, }), From 9edd24273457234ff35c84a4b78b89b48afeda05 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:49:26 +0200 Subject: [PATCH 0886/1118] Pass config entry to SMS coordinator (#149955) --- homeassistant/components/sms/__init__.py | 4 ++-- homeassistant/components/sms/coordinator.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 6c7c5374f7d..78f7899a571 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -83,8 +83,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not gateway: raise ConfigEntryNotReady(f"Cannot find device {device}") - signal_coordinator = SignalCoordinator(hass, gateway) - network_coordinator = NetworkCoordinator(hass, gateway) + signal_coordinator = SignalCoordinator(hass, entry, gateway) + network_coordinator = NetworkCoordinator(hass, entry, gateway) # Fetch initial data so we have data when entities subscribe await signal_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/sms/coordinator.py b/homeassistant/components/sms/coordinator.py index 7bc691afedf..858fc303805 100644 --- a/homeassistant/components/sms/coordinator.py +++ b/homeassistant/components/sms/coordinator.py @@ -16,13 +16,14 @@ _LOGGER = logging.getLogger(__name__) class SignalCoordinator(DataUpdateCoordinator): """Signal strength coordinator.""" - def __init__(self, hass, gateway): + def __init__(self, hass, entry, gateway): """Initialize signal strength coordinator.""" super().__init__( hass, _LOGGER, name="Device signal state", update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, ) self._gateway = gateway @@ -38,13 +39,14 @@ class SignalCoordinator(DataUpdateCoordinator): class NetworkCoordinator(DataUpdateCoordinator): """Network info coordinator.""" - def __init__(self, hass, gateway): + def __init__(self, hass, entry, gateway): """Initialize network info coordinator.""" super().__init__( hass, _LOGGER, name="Device network state", update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, ) self._gateway = gateway From 9f867f268ce0072e48248e0a1fc8aad2d95e1f0f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:58:19 +0200 Subject: [PATCH 0887/1118] Pass config entry to Snoo coordinator (#149947) --- homeassistant/components/snoo/__init__.py | 2 +- homeassistant/components/snoo/coordinator.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 54834bf58ce..20d94be7c03 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool coordinators: dict[str, SnooCoordinator] = {} tasks = [] for device in devices: - coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + coordinators[device.serialNumber] = SnooCoordinator(hass, entry, device, snoo) tasks.append(coordinators[device.serialNumber].setup()) await asyncio.gather(*tasks) entry.runtime_data = coordinators diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py index bc06d20955c..8ce0db34621 100644 --- a/homeassistant/components/snoo/coordinator.py +++ b/homeassistant/components/snoo/coordinator.py @@ -19,11 +19,18 @@ class SnooCoordinator(DataUpdateCoordinator[SnooData]): config_entry: SnooConfigEntry - def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: SnooConfigEntry, + device: SnooDevice, + snoo: Snoo, + ) -> None: """Set up Snoo Coordinator.""" super().__init__( hass, name=device.name, + config_entry=entry, logger=_LOGGER, ) self.device_unique_id = device.serialNumber From 594ce8f266902be504d7bd01328a4b070f1cd441 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 12:58:46 +0200 Subject: [PATCH 0888/1118] Pass config entry to Smarttub coordinator (#149946) --- homeassistant/components/smarttub/controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 337959e0316..095179d618a 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -74,6 +74,7 @@ class SmartTubController: self._hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_method=self.async_update_data, update_interval=timedelta(seconds=SCAN_INTERVAL), ) From a962777a2e04bf2adb17a7d80b449edee87f7a76 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:14:50 +0200 Subject: [PATCH 0889/1118] Pass config entry to Meteo France coordinator (#149945) --- homeassistant/components/meteo_france/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 20e6c02f5d4..94918ab4d4f 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"Météo-France forecast for city {entry.title}", + config_entry=entry, update_method=_async_update_data_forecast_forecast, update_interval=SCAN_INTERVAL, ) @@ -80,6 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"Météo-France rain for city {entry.title}", + config_entry=entry, update_method=_async_update_data_rain, update_interval=SCAN_INTERVAL_RAIN, ) @@ -103,6 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"Météo-France alert for department {department}", + config_entry=entry, update_method=_async_update_data_alert, update_interval=SCAN_INTERVAL, ) From 39b651e07560fac1de222a34af40989fd447b736 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:17:27 +0200 Subject: [PATCH 0890/1118] Pass config entry to Kraken coordinator (#149944) --- homeassistant/components/kraken/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index c981f3fd438..5c3158bddf2 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -135,6 +135,7 @@ class KrakenData: self._hass, _LOGGER, name=DOMAIN, + config_entry=self._config_entry, update_method=self.async_update, update_interval=timedelta( seconds=self._config_entry.options[CONF_SCAN_INTERVAL] From 3d27d501b17192728a91f7746bfe0f2448c28ea4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:20:30 +0200 Subject: [PATCH 0891/1118] Pass config entry to Mill coordinator (#149942) --- homeassistant/components/mill/__init__.py | 1 + homeassistant/components/mill/coordinator.py | 2 ++ tests/components/mill/test_coordinator.py | 19 ++++++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 246ea778916..ce258712090 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -43,6 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: historic_data_coordinator = MillHistoricDataUpdateCoordinator( hass, + entry, mill_data_connection=mill_data_connection, ) historic_data_coordinator.async_add_listener(lambda: None) diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index a701acb8ddb..ea1295376ae 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -60,6 +60,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, *, mill_data_connection: Mill, ) -> None: @@ -70,6 +71,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name="MillHistoricDataUpdateCoordinator", + config_entry=config_entry, ) async def _async_update_data(self): diff --git a/tests/components/mill/test_coordinator.py b/tests/components/mill/test_coordinator.py index a2a3bd57b65..2e6e08016b7 100644 --- a/tests/components/mill/test_coordinator.py +++ b/tests/components/mill/test_coordinator.py @@ -11,12 +11,15 @@ from homeassistant.components.recorder.statistics import statistics_during_perio from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from tests.common import MockConfigEntry from tests.components.recorder.common import async_wait_recording_done async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -31,7 +34,7 @@ async def test_mill_historic_data(recorder_mock: Recorder, hass: HomeAssistant) statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -96,6 +99,8 @@ async def test_mill_historic_data_no_heater( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -110,7 +115,7 @@ async def test_mill_historic_data_no_heater( statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -133,6 +138,8 @@ async def test_mill_historic_data_no_data( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): 2, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -145,7 +152,7 @@ async def test_mill_historic_data_no_data( mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=data) coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -168,7 +175,7 @@ async def test_mill_historic_data_no_data( mill_data_connection.fetch_historic_energy_usage = AsyncMock(return_value=None) coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) @@ -192,6 +199,8 @@ async def test_mill_historic_data_invalid_data( ) -> None: """Test historic data from Mill.""" + entry = MockConfigEntry(domain=DOMAIN) + data = { dt_util.parse_datetime("2024-12-03T00:00:00+01:00"): None, dt_util.parse_datetime("2024-12-03T01:00:00+01:00"): 3, @@ -206,7 +215,7 @@ async def test_mill_historic_data_invalid_data( statistic_id = f"{DOMAIN}:energy_dev_id" coordinator = MillHistoricDataUpdateCoordinator( - hass, mill_data_connection=mill_data_connection + hass, entry, mill_data_connection=mill_data_connection ) await coordinator._async_update_data() await async_wait_recording_done(hass) From 33eaca24d6d4cda2c7198b48e875a99be46f94ed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:21:29 +0200 Subject: [PATCH 0892/1118] Pass config entry to Simplisafe coordinator (#149943) --- homeassistant/components/simplisafe/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8a75baa69c6..67bf94c61ae 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -573,6 +573,7 @@ class SimpliSafe: self._hass, LOGGER, name=self.entry.title, + config_entry=self.entry, update_interval=DEFAULT_SCAN_INTERVAL, update_method=self.async_update, ) From 7a6aaf667bf3b80b1e53299ab6f719bef97a5779 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:27:10 +0200 Subject: [PATCH 0893/1118] Pass config entry to hue coordinator (#149941) --- homeassistant/components/hue/v1/light.py | 2 ++ homeassistant/components/hue/v1/sensor_base.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index b7251382296..36dfdd423ef 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -163,6 +163,7 @@ async def async_setup_entry( name="light", update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update), update_interval=SCAN_INTERVAL, + config_entry=config_entry, request_refresh_debouncer=Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), @@ -197,6 +198,7 @@ async def async_setup_entry( name="group", update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update), update_interval=SCAN_INTERVAL, + config_entry=config_entry, request_refresh_debouncer=Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True ), diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 393069b0c7c..fb8f3c572c1 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -53,6 +53,7 @@ class SensorManager: LOGGER, name="sensor", update_method=self.async_update_data, + config_entry=bridge.config_entry, update_interval=self.SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( bridge.hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True From 312e5903609e8292c7b25eee0a0f9f24e581d06e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:27:51 +0200 Subject: [PATCH 0894/1118] Pass config entry to Broadlink coordinator (#149949) --- homeassistant/components/broadlink/updater.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 7c1644fff54..8fdbb5054a8 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -64,6 +64,7 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]): device.hass, _LOGGER, name=f"{device.name} ({device.api.model} at {device.api.host[0]})", + config_entry=device.config, update_method=self.async_update, update_interval=self.SCAN_INTERVAL, ) From 0bdf6757c49a2544c22c344ba347d62b8d0c140f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Aug 2025 13:28:59 +0200 Subject: [PATCH 0895/1118] Pass config entry to Remote Calendar coordinator (#149958) --- homeassistant/components/remote_calendar/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 26876b53224..7a7abe37b89 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -39,6 +39,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): _LOGGER, name=f"{DOMAIN}_{config_entry.title}", update_interval=SCAN_INTERVAL, + config_entry=config_entry, always_update=True, ) self._client = get_async_client(hass) From 46cfdddc80e505c25955858c7c3a77e39132a38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Aug 2025 12:29:11 +0100 Subject: [PATCH 0896/1118] Move to the new handler for migrate_paypal_agreement (#149934) --- homeassistant/components/cloud/subscription.py | 16 +++++++++------- tests/components/cloud/conftest.py | 1 + tests/components/cloud/test_repairs.py | 9 ++++++++- tests/components/cloud/test_subscription.py | 6 ++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 9ee154dbff4..c1b8fc095c3 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -4,11 +4,13 @@ from __future__ import annotations import asyncio import logging -from typing import Any -from aiohttp.client_exceptions import ClientError -from hass_nabucasa import Cloud, cloud_api -from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo +from hass_nabucasa import ( + Cloud, + MigratePaypalAgreementInfo, + PaymentsApiError, + SubscriptionInfo, +) from .client import CloudClient from .const import REQUEST_TIMEOUT @@ -29,17 +31,17 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo async def async_migrate_paypal_agreement( cloud: Cloud[CloudClient], -) -> dict[str, Any] | None: +) -> MigratePaypalAgreementInfo | None: """Migrate a paypal agreement from legacy.""" try: async with asyncio.timeout(REQUEST_TIMEOUT): - return await cloud_api.async_migrate_paypal_agreement(cloud) + return await cloud.payments.migrate_paypal_agreement() except TimeoutError: _LOGGER.error( "A timeout of %s was reached while trying to start agreement migration", REQUEST_TIMEOUT, ) - except ClientError as exception: + except PaymentsApiError as exception: _LOGGER.error("Failed to start agreement migration - %s", exception) return None diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index e63af0ced09..a4625fcce92 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -74,6 +74,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: mock_cloud.payments = MagicMock( spec=payments_api.PaymentsApi, subscription_info=AsyncMock(), + migrate_paypal_agreement=AsyncMock(), ) mock_cloud.ice_servers = MagicMock( spec=IceServers, diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index bb3c874c077..0377ee81dba 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -4,6 +4,7 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +from hass_nabucasa.payments_api import PaymentsApiError import pytest from homeassistant.components.cloud.const import DOMAIN @@ -210,7 +211,13 @@ async def test_legacy_subscription_repair_flow_timeout( "preview": None, } - with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): + with ( + patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0), + patch( + "hass_nabucasa.payments_api.PaymentsApi.migrate_paypal_agreement", + side_effect=PaymentsApiError("some error", status=403), + ), + ): resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") assert resp.status == HTTPStatus.OK data = await resp.json() diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index c34ca1bc871..ba45e6bca57 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -25,6 +25,7 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: payments=Mock( spec=payments_api.PaymentsApi, subscription_info=AsyncMock(), + migrate_paypal_agreement=AsyncMock(), ), ) @@ -52,10 +53,7 @@ async def test_migrate_paypal_agreement_with_timeout_error( mocked_cloud: Cloud, ) -> None: """Test that we handle timeout error.""" - aioclient_mock.post( - "https://accounts.nabucasa.com/payments/migrate_paypal_agreement", - exc=TimeoutError(), - ) + mocked_cloud.payments.migrate_paypal_agreement.side_effect = TimeoutError() assert await async_migrate_paypal_agreement(mocked_cloud) is None assert ( From e2bc73f153b9a00658cd9e2652a731b86c2198a3 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 4 Aug 2025 07:35:13 -0400 Subject: [PATCH 0897/1118] Fix optimistic covers (#149962) --- homeassistant/components/template/cover.py | 1 + homeassistant/components/template/entity.py | 12 +++++++++--- tests/components/template/test_cover.py | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index caac8cf5a1d..44981fcb08f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -216,6 +216,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True + _extra_optimistic_options = (CONF_POSITION,) # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. # This ensures that the __init__ on AbstractTemplateEntity is not called twice. diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index e9a630594d7..03a93f50ec3 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -20,6 +20,7 @@ class AbstractTemplateEntity(Entity): _entity_id_format: str _optimistic_entity: bool = False + _extra_optimistic_options: tuple[str, ...] | None = None _template: Template | None = None def __init__( @@ -35,9 +36,14 @@ class AbstractTemplateEntity(Entity): if self._optimistic_entity: self._template = config.get(CONF_STATE) - self._attr_assumed_state = self._template is None or config.get( - CONF_OPTIMISTIC, False - ) + optimistic = self._template is None + if self._extra_optimistic_options: + optimistic = optimistic and all( + config.get(option) is None + for option in self._extra_optimistic_options + ) + + self._attr_assumed_state = optimistic or config.get(CONF_OPTIMISTIC, False) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index dc3428330b0..692567c7aa8 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -239,6 +239,7 @@ async def setup_position_cover( { TEST_OBJECT_ID: { **COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position_template": position_template, } }, @@ -249,6 +250,7 @@ async def setup_position_cover( count, { **NAMED_COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position": position_template, }, ) @@ -258,6 +260,7 @@ async def setup_position_cover( count, { **NAMED_COVER_ACTIONS, + "set_cover_position": SET_COVER_POSITION, "position": position_template, }, ) @@ -565,6 +568,7 @@ async def test_template_position( position: int | None, expected: str, caplog: pytest.LogCaptureFixture, + calls: list[ServiceCall], ) -> None: """Test the position_template attribute.""" hass.states.async_set(TEST_STATE_ENTITY_ID, CoverState.OPEN) @@ -580,6 +584,19 @@ async def test_template_position( assert state.state == expected assert "ValueError" not in caplog.text + # Test to make sure optimistic is not set with only a position template. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, "position": 10}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("current_position") == position + assert state.state == expected + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( From 1632e0aef69b2e6962af5084b6ea00ea59b9366b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 4 Aug 2025 13:36:12 +0200 Subject: [PATCH 0898/1118] Direct migrations with Z-Wave JS UI to docs (#149966) --- .../components/zwave_js/config_flow.py | 18 ++++++++++++++++-- homeassistant/components/zwave_js/strings.json | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 308e6c9cc1a..6121bd00508 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -93,6 +93,10 @@ MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") NETWORK_TYPE_NEW = "new" NETWORK_TYPE_EXISTING = "existing" +ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS = ( + "https://www.home-assistant.io/integrations/zwave_js/" + "#how-to-migrate-from-one-adapter-to-a-new-adapter-using-z-wave-js-ui" +) def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: @@ -446,7 +450,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): None, ) if not self._reconfigure_config_entry: - return self.async_abort(reason="addon_required") + return self.async_abort( + reason="addon_required", + description_placeholders={ + "zwave_js_ui_migration": ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS, + }, + ) vid = discovery_info.vid pid = discovery_info.pid @@ -890,7 +899,12 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry = self._reconfigure_config_entry assert config_entry is not None if not self._usb_discovery and not config_entry.data.get(CONF_USE_ADDON): - return self.async_abort(reason="addon_required") + return self.async_abort( + reason="addon_required", + description_placeholders={ + "zwave_js_ui_migration": ZWAVE_JS_UI_MIGRATION_INSTRUCTIONS, + }, + ) try: driver = self._get_driver() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 0288fbd7131..8ac356a40b0 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -4,7 +4,7 @@ "addon_get_discovery_info_failed": "Failed to get Z-Wave add-on discovery info.", "addon_info_failed": "Failed to get Z-Wave add-on info.", "addon_install_failed": "Failed to install the Z-Wave add-on.", - "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.", + "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. If you are using Z-Wave JS UI, please follow our [migration instructions]({zwave_js_ui_migration}).", "addon_set_config_failed": "Failed to set Z-Wave configuration.", "addon_start_failed": "Failed to start the Z-Wave add-on.", "addon_stop_failed": "Failed to stop the Z-Wave add-on.", From 822e1ffc8d8b63d3712e6a85905ba99a764ee403 Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 4 Aug 2025 20:27:15 +0800 Subject: [PATCH 0899/1118] Minor UI improvements for Telegram bot actions (#149889) --- .../components/telegram_bot/services.yaml | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index ce7ebea2b66..0ebe7988642 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -18,7 +18,8 @@ send_message: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -43,7 +44,8 @@ send_message: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text @@ -98,10 +100,12 @@ send_photo: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -126,7 +130,8 @@ send_photo: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -180,10 +185,12 @@ send_sticker: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -199,7 +206,8 @@ send_sticker: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -253,10 +261,12 @@ send_animation: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -281,7 +291,8 @@ send_animation: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -335,10 +346,12 @@ send_video: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -363,7 +376,8 @@ send_video: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -417,10 +431,12 @@ send_voice: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -436,7 +452,8 @@ send_voice: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -490,10 +507,12 @@ send_document: example: myuser_pwd selector: text: + type: password target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true parse_mode: selector: select: @@ -518,7 +537,8 @@ send_document: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -563,7 +583,8 @@ send_location: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true disable_notification: selector: boolean: @@ -576,7 +597,8 @@ send_location: keyboard: example: '["/command1, /command2", "/command3"]' selector: - object: + text: + multiple: true inline_keyboard: example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], @@ -605,7 +627,8 @@ send_poll: target: example: "[12345, 67890] or 12345" selector: - object: + text: + multiple: true question: required: true selector: From b76f47cd9ff3d14c84be44ac6cce74a5776f3675 Mon Sep 17 00:00:00 2001 From: hanwg Date: Mon, 4 Aug 2025 20:32:48 +0800 Subject: [PATCH 0900/1118] Add bot details to Telegram bot events (#148638) --- homeassistant/components/telegram_bot/bot.py | 20 +++++++++++++++++- .../components/telegram_bot/polling.py | 2 +- .../components/telegram_bot/webhooks.py | 2 +- tests/components/telegram_bot/conftest.py | 2 +- .../telegram_bot/test_telegram_bot.py | 21 ++++++++++++++++++- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index c57648c9551..3145badbed7 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -101,13 +101,26 @@ _LOGGER = logging.getLogger(__name__) type TelegramBotConfigEntry = ConfigEntry[TelegramNotificationService] +def _get_bot_info(bot: Bot, config_entry: ConfigEntry) -> dict[str, Any]: + return { + "config_entry_id": config_entry.entry_id, + "id": bot.id, + "first_name": bot.first_name, + "last_name": bot.last_name, + "username": bot.username, + } + + class BaseTelegramBot: """The base class for the telegram bot.""" - def __init__(self, hass: HomeAssistant, config: TelegramBotConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config: TelegramBotConfigEntry, bot: Bot + ) -> None: """Initialize the bot base class.""" self.hass = hass self.config = config + self._bot = bot @abstractmethod async def shutdown(self) -> None: @@ -134,6 +147,8 @@ class BaseTelegramBot: _LOGGER.warning("Unhandled update: %s", update) return True + event_data["bot"] = _get_bot_info(self._bot, self.config) + event_context = Context() _LOGGER.debug("Firing event %s: %s", event_type, event_data) @@ -442,6 +457,9 @@ class TelegramNotificationService: event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ ATTR_MESSAGE_THREAD_ID ] + + event_data["bot"] = _get_bot_info(self.bot, self.config) + self.hass.bus.async_fire( EVENT_TELEGRAM_SENT, event_data, context=context ) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 6c38a0e53b8..b8640c5c005 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -54,7 +54,7 @@ class PollBot(BaseTelegramBot): self, hass: HomeAssistant, bot: Bot, config: TelegramBotConfigEntry ) -> None: """Create Application to poll for updates.""" - super().__init__(hass, config) + super().__init__(hass, config, bot) self.bot = bot self.application = ApplicationBuilder().bot(self.bot).build() self.application.add_handler(TypeHandler(Update, self.handle_update)) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 29c3305858b..61843e6ffbf 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -77,7 +77,7 @@ class PushBot(BaseTelegramBot): # Dumb Application that just gets our updates to our handler callback (self.handle_update) self.application = ApplicationBuilder().bot(bot).updater(None).build() self.application.add_handler(TypeHandler(Update, self.handle_update)) - super().__init__(hass, config) + super().__init__(hass, config, bot) self.base_url = config.data.get(CONF_URL) or get_url( hass, require_ssl=True, allow_internal=False diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 66c3c43ea86..489cb034ac2 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -96,7 +96,7 @@ def mock_external_calls() -> Generator[None]: max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, ) - test_user = User(123456, "Testbot", True) + test_user = User(123456, "Testbot", True, "mock last name", "mock username") message = Message( message_id=12345, date=datetime.now(), diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 80b9859ceab..eec2bd5ecf7 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -174,6 +174,15 @@ async def test_send_message( assert len(events) == 1 assert events[0].context == context + config_entry = hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "1234567890:ABC" + ) + assert events[0].data["bot"]["config_entry_id"] == config_entry.entry_id + assert events[0].data["bot"]["id"] == 123456 + assert events[0].data["bot"]["first_name"] == "Testbot" + assert events[0].data["bot"]["last_name"] == "mock last name" + assert events[0].data["bot"]["username"] == "mock username" + assert len(response["chats"]) == 1 assert (response["chats"][0]["message_id"]) == 12345 @@ -479,6 +488,16 @@ async def test_polling_platform_message_text_update( assert len(events) == 1 assert events[0].data["text"] == update_message_text["message"]["text"] + + config_entry = hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "1234567890:ABC" + ) + assert events[0].data["bot"]["config_entry_id"] == config_entry.entry_id + assert events[0].data["bot"]["id"] == 123456 + assert events[0].data["bot"]["first_name"] == "Testbot" + assert events[0].data["bot"]["last_name"] == "mock last name" + assert events[0].data["bot"]["username"] == "mock username" + assert isinstance(events[0].context, Context) @@ -752,7 +771,7 @@ async def test_send_message_no_chat_id_error( ) assert err.value.translation_key == "missing_allowed_chat_ids" - assert err.value.translation_placeholders["bot_name"] == "Testbot" + assert err.value.translation_placeholders["bot_name"] == "Testbot mock last name" async def test_send_message_config_entry_error( From 88c9d5dbe3804474f1fb727614a21af44a7df35f Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 4 Aug 2025 15:35:41 +0200 Subject: [PATCH 0901/1118] Fix bsblan reauthentication (#149926) --- .../components/bsblan/config_flow.py | 32 ++-- tests/components/bsblan/test_config_flow.py | 158 ++++++++++++++++++ 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 1491322ae13..5f4f67a114a 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -211,16 +211,16 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - # Use existing host and port, update auth credentials - self.host = existing_entry.data[CONF_HOST] - self.port = existing_entry.data[CONF_PORT] - self.passkey = user_input.get(CONF_PASSKEY) or existing_entry.data.get( - CONF_PASSKEY - ) - self.username = user_input.get(CONF_USERNAME) or existing_entry.data.get( - CONF_USERNAME - ) - self.password = user_input.get(CONF_PASSWORD) + # Combine existing data with the user's new input for validation. + # This correctly handles adding, changing, and clearing credentials. + config_data = existing_entry.data.copy() + config_data.update(user_input) + + self.host = config_data[CONF_HOST] + self.port = config_data[CONF_PORT] + self.passkey = config_data.get(CONF_PASSKEY) + self.username = config_data.get(CONF_USERNAME) + self.password = config_data.get(CONF_PASSWORD) try: await self._get_bsblan_info(raise_on_progress=False, is_reauth=True) @@ -267,17 +267,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # Update the config entry with new auth data - data_updates = {} - if self.passkey is not None: - data_updates[CONF_PASSKEY] = self.passkey - if self.username is not None: - data_updates[CONF_USERNAME] = self.username - if self.password is not None: - data_updates[CONF_PASSWORD] = self.password - + # Update only the fields that were provided by the user return self.async_update_reload_and_abort( - existing_entry, data_updates=data_updates, reason="reauth_successful" + existing_entry, data_updates=user_input, reason="reauth_successful" ) @callback diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 3ca0de5b78f..a06131f7216 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -866,6 +866,164 @@ async def test_reauth_flow_partial_credentials_update( assert mock_config_entry.data[CONF_PORT] == 80 +async def test_reauth_flow_preserves_non_credential_fields( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow preserves non-credential fields using data_updates.""" + # Create a config entry with additional custom fields that should be preserved + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "old_key", + CONF_USERNAME: "old_user", + CONF_PASSWORD: "old_pass", + # Add some custom fields that should be preserved + "custom_field": "should_be_preserved", + "another_field": 42, + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with only new credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "new_key", + CONF_USERNAME: "new_user", + CONF_PASSWORD: "new_pass", + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that only the provided fields were updated, others preserved + assert entry.data[CONF_PASSKEY] == "new_key" # Updated + assert entry.data[CONF_USERNAME] == "new_user" # Updated + assert entry.data[CONF_PASSWORD] == "new_pass" # Updated + + # These fields should remain unchanged (preserved by data_updates) + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + assert entry.data["custom_field"] == "should_be_preserved" + assert entry.data["another_field"] == 42 + + +async def test_reauth_flow_clears_credentials_with_empty_strings( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow can clear credentials by providing empty strings.""" + # Create a config entry with existing credentials + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "existing_key", + CONF_USERNAME: "existing_user", + CONF_PASSWORD: "existing_pass", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with empty strings to clear credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "", # Clear passkey + CONF_USERNAME: "", # Clear username + CONF_PASSWORD: "", # Clear password + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify that credentials were cleared (set to empty strings) + assert entry.data[CONF_PASSKEY] == "" + assert entry.data[CONF_USERNAME] == "" + assert entry.data[CONF_PASSWORD] == "" + + # Host and port should remain unchanged + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + + +async def test_reauth_flow_partial_clear_credentials( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test reauth flow can partially clear some credentials while updating others.""" + # Create a config entry with existing credentials + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "existing_key", + CONF_USERNAME: "existing_user", + CONF_PASSWORD: "existing_pass", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + ) + + # Submit with mix of clearing and updating credentials + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "", # Clear passkey + CONF_USERNAME: "new_user", # Update username + CONF_PASSWORD: "", # Clear password + }, + ) + + _assert_abort_result(result, "reauth_successful") + + # Verify mixed update: some cleared, some updated, some preserved + assert entry.data[CONF_PASSKEY] == "" # Cleared + assert entry.data[CONF_USERNAME] == "new_user" # Updated + assert entry.data[CONF_PASSWORD] == "" # Cleared + + # Host and port should remain unchanged + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_PORT] == 80 + + async def test_zeroconf_discovery_auth_error_during_confirm( hass: HomeAssistant, mock_bsblan: MagicMock, From ae48179e959c8cc42dce3bb31d32136d34800e1e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 4 Aug 2025 15:58:57 +0200 Subject: [PATCH 0902/1118] Bump zwave-js-server-python to 0.67.1 (#149972) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 2cad8df3805..153e8e6a7fe 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.67.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 8dcc7478d7c..1c7280547c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3215,7 +3215,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.67.0 +zwave-js-server-python==0.67.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce01dbd5a51..4aa7a5063e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2650,7 +2650,7 @@ zeversolar==0.3.2 zha==0.0.65 # homeassistant.components.zwave_js -zwave-js-server-python==0.67.0 +zwave-js-server-python==0.67.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From fac5b2c09cac9fffc6dc682539124e0c22f05041 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 16:58:46 +0200 Subject: [PATCH 0903/1118] Add Tuya snapshots tests for camera platform (#149959) --- tests/components/tuya/__init__.py | 11 + .../tuya/fixtures/sp_sdd5f5f2dl5wydjf.json | 383 ++++++++++++++++++ .../tuya/snapshots/test_camera.ambr | 162 ++++++++ .../tuya/snapshots/test_number.ambr | 58 +++ .../tuya/snapshots/test_select.ambr | 173 ++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++ .../components/tuya/snapshots/test_siren.ambr | 49 +++ .../tuya/snapshots/test_switch.ambr | 336 +++++++++++++++ tests/components/tuya/test_camera.py | 73 ++++ 9 files changed, 1298 insertions(+) create mode 100644 tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json create mode 100644 tests/components/tuya/snapshots/test_camera.ambr create mode 100644 tests/components/tuya/test_camera.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7d6cd32959c..05d636b8393 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -306,16 +306,27 @@ DEVICE_MOCKS = { ], "sp_drezasavompxpcgm": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], "sp_rjKXWRohlvOTyLBu": [ # https://github.com/home-assistant/core/issues/149704 + Platform.CAMERA, Platform.LIGHT, Platform.SELECT, Platform.SWITCH, ], + "sp_sdd5f5f2dl5wydjf": [ + # https://github.com/home-assistant/core/issues/144087 + Platform.CAMERA, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SIREN, + Platform.SWITCH, + ], "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, diff --git a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json new file mode 100644 index 00000000000..7e4705650b1 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json @@ -0,0 +1,383 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3f8b448bbc123e29oghf", + "name": "C9", + "category": "sp", + "product_id": "sdd5f5f2dl5wydjf", + "product_name": "Security Camera", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-03-13T07:28:30+00:00", + "create_time": "2025-03-13T07:28:30+00:00", + "update_time": "2025-03-13T07:28:30+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_wdr": { + "type": "Boolean", + "value": {} + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4", "5", "6", "7"] + } + }, + "ipc_auto_siren": { + "type": "Boolean", + "value": {} + }, + "nightvision_mode": { + "type": "Enum", + "value": { + "range": ["auto", "ir_mode", "color_mode"] + } + }, + "battery_report_cap": { + "type": "Integer", + "value": { + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "doorbell_active": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "wireless_electricity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "wireless_powermode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "wireless_lowpower": { + "type": "Integer", + "value": { + "min": 10, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "wireless_awake": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "pir_switch": { + "type": "Enum", + "value": { + "range": ["0", "1", "2", "3", "4"] + } + }, + "doorbell_pic": { + "type": "Raw", + "value": {} + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "basic_device_volume": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 10, + "scale": 0, + "step": 1 + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "device_restart": { + "type": "Boolean", + "value": {} + }, + "humanoid_filter": { + "type": "Boolean", + "value": {} + }, + "cruise_switch": { + "type": "Boolean", + "value": {} + }, + "cruise_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "ipc_work_mode": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "initiative_message": { + "type": "Raw", + "value": {} + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "motion_sensitivity": 1, + "basic_wdr": false, + "sd_storge": "30932992|3407872|27525120", + "sd_status": 1, + "sd_format": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 5, + "ipc_auto_siren": false, + "nightvision_mode": "auto", + "battery_report_cap": 1, + "ptz_calibration": false, + "motion_switch": true, + "doorbell_active": "", + "wireless_electricity": 80, + "wireless_powermode": 0, + "wireless_lowpower": 10, + "wireless_awake": false, + "record_switch": true, + "record_mode": 1, + "pir_switch": 2, + "doorbell_pic": "", + "siren_switch": false, + "basic_device_volume": 1, + "motion_tracking": true, + "device_restart": false, + "humanoid_filter": true, + "cruise_switch": false, + "cruise_mode": 0, + "alarm_message": "**REDACTED**", + "ipc_work_mode": 0, + "initiative_message": "" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr new file mode 100644 index 00000000000..e1945f03d3c --- /dev/null +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -0,0 +1,162 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', + 'friendly_name': 'CAM GARAGE', + 'model_name': 'Indoor camera ', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_porch', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', + 'friendly_name': 'CAM PORCH', + 'model_name': 'Indoor cam Pan/Tilt ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_porch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.c9', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.c9?token=1', + 'friendly_name': 'C9', + 'model_name': 'Security Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 9a04b9dd78c..fa9d7358314 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -527,6 +527,64 @@ 'state': '10.0', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.c9_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_device_volume', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Volume', + 'max': 10.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.c9_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 943e230b7cd..84af76355d5 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -945,6 +945,179 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_ipc_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': 'IPC mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfipc_work_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 IPC mode', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.c9_ipc_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index e8b9900185e..d2cd0eb0676 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2556,6 +2556,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_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.c9_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfwireless_electricity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'C9 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.c9_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 5c46c2bbd19..876db171c7b 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -97,3 +97,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.c9', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfsiren_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.c9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1b90c21bb46..aa80ac08ee5 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1934,6 +1934,342 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-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': , + 'entity_id': 'switch.c9_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Flip', + }), + 'context': , + 'entity_id': 'switch.c9_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion alarm', + }), + 'context': , + 'entity_id': 'switch.c9_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-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': , + 'entity_id': 'switch.c9_motion_recording', + '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': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion recording', + }), + 'context': , + 'entity_id': 'switch.c9_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-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': , + 'entity_id': 'switch.c9_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion tracking', + }), + 'context': , + 'entity_id': 'switch.c9_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-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': , + 'entity_id': 'switch.c9_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Time watermark', + }), + 'context': , + 'entity_id': 'switch.c9_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-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': , + 'entity_id': 'switch.c9_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Video recording', + }), + 'context': , + 'entity_id': 'switch.c9_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-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': , + 'entity_id': 'switch.c9_wide_dynamic_range', + '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': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py new file mode 100644 index 00000000000..25bfe57ea0c --- /dev/null +++ b/tests/components/tuya/test_camera.py @@ -0,0 +1,73 @@ +"""Test Tuya camera platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock camera access token which normally is randomized.""" + with patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ): + yield + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 31e647b5b004fa42e32aedec6391f85185b046b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Aug 2025 15:59:07 +0100 Subject: [PATCH 0904/1118] Bump hass-nabucasa from 0.110.1 to 0.111.0 (#149977) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 63eae6261d4..0ef407b3628 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.110.1"], + "requirements": ["hass-nabucasa==0.111.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f8a57ba61bb..bca5e4648af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250731.0 diff --git a/pyproject.toml b/pyproject.toml index a32e9308fe2..99ea68be900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.110.1", + "hass-nabucasa==0.111.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index ba08a72e324..90953842e20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c7280547c7..6e93cb6c595 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4aa7a5063e6..945c16d3250 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.110.1 +hass-nabucasa==0.111.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 73ca6b490043f7351ef9d256b1bdd96a7eb60b26 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 4 Aug 2025 11:40:11 -0400 Subject: [PATCH 0905/1118] Add translation strings for unsupported OS version (#149837) --- homeassistant/components/hassio/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 1e312ee34d9..2b87a7632a0 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -226,6 +226,10 @@ "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." + }, + "unsupported_os_version": { + "title": "Unsupported system - Home Assistant OS version", + "description": "System is unsupported because the Home Assistant OS version in use is not supported. Use the link to learn more and how to fix this." } }, "entity": { From 94f2118b19941c064ced5d2852027115b82a7e99 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Aug 2025 20:34:07 +0200 Subject: [PATCH 0906/1118] Fix flaky history_stats test case (#149974) --- tests/components/history_stats/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index a1f0a080b8a..08dbefe7465 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -400,10 +400,10 @@ async def test_options_flow_preview( msg = await client.receive_json() assert msg["event"]["state"] == exp_count - hass.states.async_set(monitored_entity, "on") + hass.states.async_set(monitored_entity, "on") - msg = await client.receive_json() - assert msg["event"]["state"] == "3" + msg = await client.receive_json() + assert msg["event"]["state"] == "3" async def test_options_flow_preview_errors( From a9621ac81194f4c4ea99d7938b8c2f4d3e3d2ff8 Mon Sep 17 00:00:00 2001 From: markhannon Date: Tue, 5 Aug 2025 04:41:05 +1000 Subject: [PATCH 0907/1118] Add tests for Zimi entitites (#144292) --- tests/components/zimi/common.py | 81 ++++++++++++ tests/components/zimi/conftest.py | 28 +++++ .../components/zimi/snapshots/test_cover.ambr | 17 +++ tests/components/zimi/snapshots/test_fan.ambr | 19 +++ .../components/zimi/snapshots/test_light.ambr | 38 ++++++ .../zimi/snapshots/test_switch.ambr | 14 +++ tests/components/zimi/test_cover.py | 77 ++++++++++++ tests/components/zimi/test_fan.py | 75 +++++++++++ tests/components/zimi/test_light.py | 119 ++++++++++++++++++ tests/components/zimi/test_switch.py | 60 +++++++++ 10 files changed, 528 insertions(+) create mode 100644 tests/components/zimi/common.py create mode 100644 tests/components/zimi/conftest.py create mode 100644 tests/components/zimi/snapshots/test_cover.ambr create mode 100644 tests/components/zimi/snapshots/test_fan.ambr create mode 100644 tests/components/zimi/snapshots/test_light.ambr create mode 100644 tests/components/zimi/snapshots/test_switch.ambr create mode 100644 tests/components/zimi/test_cover.py create mode 100644 tests/components/zimi/test_fan.py create mode 100644 tests/components/zimi/test_light.py create mode 100644 tests/components/zimi/test_switch.py diff --git a/tests/components/zimi/common.py b/tests/components/zimi/common.py new file mode 100644 index 00000000000..13582b3d42c --- /dev/null +++ b/tests/components/zimi/common.py @@ -0,0 +1,81 @@ +"""Common items for testing the zimi component.""" + +from unittest.mock import MagicMock, create_autospec, patch + +from zcc.device import ControlPointDevice + +from homeassistant.components.zimi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +DEVICE_INFO = { + "id": "test-device-id", + "name": "unknown", + "manufacturer": "Zimi", + "model": "Controller XYZ", + "hwVersion": "2.2.2", + "fwVersion": "3.3.3", +} + +ENTITY_INFO = { + "id": "test-entity-id", + "name": "Test Entity Name", + "room": "Test Entity Room", + "type": "unknown", +} + +INPUT_HOST = "192.168.1.100" +INPUT_PORT = 5003 + + +def mock_api_device( + device_name: str | None = None, + entity_type: str | None = None, +) -> MagicMock: + """Mock a Zimi ControlPointDevice which is used in the zcc API with defaults.""" + + mock_api_device = create_autospec(ControlPointDevice) + + mock_api_device.identifier = ENTITY_INFO["id"] + mock_api_device.room = ENTITY_INFO["room"] + mock_api_device.name = ENTITY_INFO["name"] + mock_api_device.type = entity_type or ENTITY_INFO["type"] + + mock_manfacture_info = MagicMock() + mock_manfacture_info.identifier = DEVICE_INFO["id"] + mock_manfacture_info.manufacturer = DEVICE_INFO["manufacturer"] + mock_manfacture_info.model = DEVICE_INFO["model"] + mock_manfacture_info.name = device_name or DEVICE_INFO["name"] + mock_manfacture_info.hwVersion = DEVICE_INFO["hwVersion"] + mock_manfacture_info.firmwareVersion = DEVICE_INFO["fwVersion"] + + mock_api_device.manufacture_info = mock_manfacture_info + + mock_api_device.brightness = 0 + mock_api_device.percentage = 0 + + return mock_api_device + + +async def setup_platform( + hass: HomeAssistant, + platform: str, +) -> MockConfigEntry: + """Set up the specified Zimi platform.""" + + if not platform: + raise ValueError("Platform must be specified") + + mock_config = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: INPUT_HOST, CONF_PORT: INPUT_PORT} + ) + mock_config.add_to_hass(hass) + + with patch("homeassistant.components.zimi.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_config diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py new file mode 100644 index 00000000000..44d898deffb --- /dev/null +++ b/tests/components/zimi/conftest.py @@ -0,0 +1,28 @@ +"""Test fixtures for Zimi component.""" + +from unittest.mock import patch + +import pytest + +INPUT_MAC = "aa:bb:cc:dd:ee:ff" + + +API_INFO = { + "brand": "Zimi", + "network_name": "Test Network", + "firmware_version": "1.1.1", +} + + +@pytest.fixture +def mock_api(): + """Mock the API with defaults.""" + with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: + mock_api = mock.return_value + mock_api.connect.return_value = True + mock_api.mac = INPUT_MAC + mock_api.brand = API_INFO["brand"] + mock_api.network_name = API_INFO["network_name"] + mock_api.firmware_version = API_INFO["firmware_version"] + + yield mock_api diff --git a/tests/components/zimi/snapshots/test_cover.ambr b/tests/components/zimi/snapshots/test_cover.ambr new file mode 100644 index 00000000000..66d74f36771 --- /dev/null +++ b/tests/components/zimi/snapshots/test_cover.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_cover_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'garage', + 'friendly_name': 'Cover Controller Test Entity Name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_fan.ambr b/tests/components/zimi/snapshots/test_fan.ambr new file mode 100644 index 00000000000..6b3f226b4f9 --- /dev/null +++ b/tests/components/zimi/snapshots/test_fan.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_fan_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fan Controller Test Entity Name', + 'percentage': 1, + 'percentage_step': 12.5, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fan_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_light.ambr b/tests/components/zimi/snapshots/test_light.ambr new file mode 100644 index 00000000000..372e2c937ca --- /dev/null +++ b/tests/components/zimi/snapshots/test_light.ambr @@ -0,0 +1,38 @@ +# serializer version: 1 +# name: test_dimmer_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 0, + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Light Controller Test Entity Name', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/snapshots/test_switch.ambr b/tests/components/zimi/snapshots/test_switch.ambr new file mode 100644 index 00000000000..c96fc99b908 --- /dev/null +++ b/tests/components/zimi/snapshots/test_switch.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_switch_entity + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch Controller Test Entity Name', + }), + 'context': , + 'entity_id': 'switch.switch_controller_test_entity_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/zimi/test_cover.py b/tests/components/zimi/test_cover.py new file mode 100644 index 00000000000..68809af49e6 --- /dev/null +++ b/tests/components/zimi/test_cover.py @@ -0,0 +1,77 @@ +"""Test the Zimi cover entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_cover_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests cover entity.""" + + device_name = "Cover Controller" + entity_key = "cover.cover_controller_test_entity_name" + entity_type = Platform.COVER + + mock_api.doors = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_CLOSE_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_CLOSE_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].close_door.called + + assert SERVICE_OPEN_COVER in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_OPEN_COVER, + {"entity_id": entity_key}, + blocking=True, + ) + assert mock_api.doors[0].open_door.called + + assert SERVICE_SET_COVER_POSITION in services[entity_type] + await hass.services.async_call( + entity_type, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_key, "position": 50}, + blocking=True, + ) + assert mock_api.doors[0].open_to_percentage.called diff --git a/tests/components/zimi/test_fan.py b/tests/components/zimi/test_fan.py new file mode 100644 index 00000000000..ed87b32a61f --- /dev/null +++ b/tests/components/zimi/test_fan.py @@ -0,0 +1,75 @@ +"""Test the Zimi fan entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_fan_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests fan entity.""" + + device_name = "Fan Controller" + entity_key = "fan.fan_controller_test_entity_name" + entity_type = Platform.FAN + + mock_api.fans = [mock_api_device(device_name=device_name, entity_type=entity_type)] + + await setup_platform(hass, entity_type) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert ( + entity.supported_features + == FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.fans[0].turn_off.called + + assert "set_percentage" in services[entity_type] + await hass.services.async_call( + entity_type, + "set_percentage", + {"entity_id": entity_key, "percentage": 50}, + blocking=True, + ) + assert mock_api.fans[0].set_fanspeed.called diff --git a/tests/components/zimi/test_light.py b/tests/components/zimi/test_light.py new file mode 100644 index 00000000000..7716a6368fe --- /dev/null +++ b/tests/components/zimi/test_light.py @@ -0,0 +1,119 @@ +"""Test the Zimi light entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ColorMode +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_light_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests lights entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.ONOFF], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].turn_off.called + + +async def test_dimmer_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests dimmer entity.""" + + device_name = "Light Controller" + entity_key = "light.light_controller_test_entity_name" + entity_type = "dimmer" + entity_type_override = "light" + + mock_api.lights = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.LIGHT) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + assert entity.capabilities == { + "supported_color_modes": [ColorMode.BRIGHTNESS], + } + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called + + assert SERVICE_TURN_OFF in services[entity_type_override] + + await hass.services.async_call( + entity_type_override, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.lights[0].set_brightness.called diff --git a/tests/components/zimi/test_switch.py b/tests/components/zimi/test_switch.py new file mode 100644 index 00000000000..2464757e7b6 --- /dev/null +++ b/tests/components/zimi/test_switch.py @@ -0,0 +1,60 @@ +"""Test the Zimi switch entity.""" + +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ENTITY_INFO, mock_api_device, setup_platform + + +async def test_switch_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Tests switch entity.""" + + device_name = "Switch Controller" + entity_key = "switch.switch_controller_test_entity_name" + entity_type = "switch" + + mock_api.outlets = [ + mock_api_device(device_name=device_name, entity_type=entity_type) + ] + + await setup_platform(hass, Platform.SWITCH) + + entity = entity_registry.entities[entity_key] + assert entity.unique_id == ENTITY_INFO["id"] + + state = hass.states.get(entity_key) + assert state == snapshot + + services = hass.services.async_services() + + assert SERVICE_TURN_ON in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_ON, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_on.called + + assert SERVICE_TURN_OFF in services[entity_type] + + await hass.services.async_call( + entity_type, + SERVICE_TURN_OFF, + {"entity_id": entity_key}, + blocking=True, + ) + + assert mock_api.outlets[0].turn_off.called From 1fbce01e26d6e7db5163e94bdfaf0577f7ea9255 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:30:43 +0200 Subject: [PATCH 0908/1118] Add initial support for Tuya wg2 category (#149676) --- .../components/tuya/binary_sensor.py | 10 +++ tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/wg2_nwxr8qcu4seltoro.json | 90 +++++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++++++++ 4 files changed, 153 insertions(+) create mode 100644 tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 4fef11a7335..fd3f0cfcb7e 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -314,6 +314,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Zigbee gateway + # Undocumented + "wg2": ( + TuyaBinarySensorEntityDescription( + key=DPCode.MASTER_STATE, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + on_value="alarm", + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 05d636b8393..a66bd314185 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -346,6 +346,10 @@ DEVICE_MOCKS = { Platform.CLIMATE, Platform.SWITCH, ], + "wg2_nwxr8qcu4seltoro": [ + # https://github.com/orgs/home-assistant/discussions/430 + Platform.BINARY_SENSOR, + ], "wk_fi6dne5tu4t1nm6j": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json new file mode 100644 index 00000000000..0e39f713dd0 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json @@ -0,0 +1,90 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1752690839034sq255y", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf79ca977d67322eb2o68m", + "name": "X5 Zigbee Gateway", + "category": "wg2", + "product_id": "nwxr8qcu4seltoro", + "product_name": "X5", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-14T10:19:21+00:00", + "create_time": "2025-07-14T10:19:21+00:00", + "update_time": "2025-07-14T10:19:21+00:00", + "function": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status_range": { + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "master_information": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "master_language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "french", + "italian", + "german", + "spanish", + "portuguese", + "russian", + "japanese" + ] + } + } + }, + "status": { + "master_state": "normal", + "master_information": "", + "factory_reset": false, + "master_language": "chinese_simplified" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 7cb613ebbf2..6ae0b4997dd 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -685,6 +685,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-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.x5_zigbee_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf79ca977d67322eb2o68mmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'X5 Zigbee Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4d53450cbfb5b4e8f1f8326cc76971388be778e9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:54:50 -0400 Subject: [PATCH 0909/1118] Create battery_level deprecation repair for template vacuum platform (#149987) Co-authored-by: Norbert Rittel --- .../components/template/strings.json | 6 +++ homeassistant/components/template/vacuum.py | 47 ++++++++++++++++++- tests/components/template/test_vacuum.py | 33 ++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index be5fb1866ea..96c8435c25c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -440,6 +440,12 @@ } } }, + "issues": { + "deprecated_battery_level": { + "title": "Deprecated battery level option in {entity_name}", + "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name})." + } + }, "options": { "step": { "alarm_control_panel": { diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1abfdbd00da..242a534187a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + template, +) from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -188,6 +193,26 @@ def async_create_preview_vacuum( ) +def create_issue( + hass: HomeAssistant, supported_features: int, name: str, entity_id: str +) -> None: + """Create the battery_level issue.""" + if supported_features & VacuumEntityFeature.BATTERY: + key = "deprecated_battery_level" + ir.async_create_issue( + hass, + DOMAIN, + f"{key}_{entity_id}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=key, + translation_placeholders={ + "entity_name": name, + "entity_id": entity_id, + }, + ) + + class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" @@ -369,6 +394,16 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -434,6 +469,16 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): self._to_render_simple.append(key) self._parse_result.add(key) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + create_issue( + self.hass, + self._attr_supported_features, + self._attr_name or DEFAULT_NAME, + self.entity_id, + ) + @callback def _handle_coordinator_update(self) -> None: """Handle update of the data.""" diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 6c7222645b6..d0e6488e46e 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -15,7 +15,7 @@ from homeassistant.components.vacuum import ( 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 import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component @@ -589,6 +589,37 @@ async def test_battery_level_template( _verify(hass, STATE_UNKNOWN, expected) +@pytest.mark.parametrize( + ("count", "state_template", "extra_config", "attribute_template"), + [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "battery_level_template"), + (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") +async def test_battery_level_template_repair( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test battery_level template raises issue.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, VacuumActivity.DOCKED) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "template", f"deprecated_battery_level_{TEST_ENTITY_ID}" + ) + assert issue.domain == "template" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID + assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + + @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ From 99d580e371c9d15c87fedc9a65ac793364ef9f78 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:28:34 +0200 Subject: [PATCH 0910/1118] Add reset cutting blade usage time to Husqvarna Automower (#149628) --- .../components/husqvarna_automower/button.py | 25 +++++++ .../components/husqvarna_automower/icons.json | 3 + .../husqvarna_automower/strings.json | 3 + .../snapshots/test_button.ambr | 48 ++++++++++++++ .../husqvarna_automower/test_button.py | 65 ++++++++++--------- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 8e58a309e59..b39f2138ab4 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -21,6 +21,20 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 +async def async_reset_cutting_blade_usage_time( + session: AutomowerSession, + mower_id: str, +) -> None: + """Reset cutting blade usage time.""" + await session.commands.reset_cutting_blade_usage_time(mower_id) + + +def reset_cutting_blade_usage_time_availability(data: MowerAttributes) -> bool: + """Return True if blade usage time is greater than 0.""" + value = data.statistics.cutting_blade_usage_time + return value is not None and value > 0 + + @dataclass(frozen=True, kw_only=True) class AutomowerButtonEntityDescription(ButtonEntityDescription): """Describes Automower button entities.""" @@ -28,6 +42,7 @@ class AutomowerButtonEntityDescription(ButtonEntityDescription): available_fn: Callable[[MowerAttributes], bool] = lambda _: True exists_fn: Callable[[MowerAttributes], bool] = lambda _: True press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] + poll_after_sending: bool = False MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( @@ -43,6 +58,14 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( translation_key="sync_clock", press_fn=lambda session, mower_id: session.commands.set_datetime(mower_id), ), + AutomowerButtonEntityDescription( + key="reset_cutting_blade_usage_time", + translation_key="reset_cutting_blade_usage_time", + available_fn=reset_cutting_blade_usage_time_availability, + exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, + press_fn=async_reset_cutting_blade_usage_time, + poll_after_sending=True, + ), ) @@ -93,3 +116,5 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): async def async_press(self) -> None: """Send a command to the mower.""" await self.entity_description.press_fn(self.coordinator.api, self.mower_id) + if self.entity_description.poll_after_sending: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e9d023bd3cc..5ff5940bdf4 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,9 @@ "button": { "sync_clock": { "default": "mdi:clock-check-outline" + }, + "reset_cutting_blade_usage_time": { + "default": "mdi:saw-blade" } }, "number": { diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 226c9ee17f0..bd8a9346552 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -53,6 +53,9 @@ }, "sync_clock": { "name": "Sync clock" + }, + "reset_cutting_blade_usage_time": { + "name": "Reset cutting blade usage time" } }, "number": { diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 3d48125aa9a..058fc214a91 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -47,6 +47,54 @@ 'state': 'unavailable', }) # --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_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': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + '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': 'Reset cutting blade usage time', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_cutting_blade_usage_time', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_reset_cutting_blade_usage_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_reset_cutting_blade_usage_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Reset cutting blade usage time', + }), + 'context': , + 'entity_id': 'button.test_mower_1_reset_cutting_blade_usage_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_snapshot[button.test_mower_1_sync_clock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 9fb5ad28c89..dcb4252ac8e 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat @pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) -async def test_button_states_and_commands( +async def test_button_error_confirm( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -58,42 +58,43 @@ async def test_button_states_and_commands( state = hass.states.get(entity_id) assert state.state == STATE_UNKNOWN - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_automower_client.commands.error_confirm.assert_called_once_with(TEST_MOWER_ID) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == "2023-06-05T00:16:00+00:00" - mock_automower_client.commands.error_confirm.side_effect = ApiError("Test error") - with pytest.raises( - HomeAssistantError, - match="Failed to send command: Test error", - ): - await hass.services.async_call( - domain="button", - service=SERVICE_PRESS, - target={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - +@pytest.mark.parametrize( + ("entity_id", "name", "expected_command"), + [ + ( + "button.test_mower_1_confirm_error", + "Test Mower 1 Confirm error", + "error_confirm", + ), + ( + "button.test_mower_1_sync_clock", + "Test Mower 1 Sync clock", + "set_datetime", + ), + ( + "button.test_mower_1_reset_cutting_blade_usage_time", + "Test Mower 1 Reset cutting blade usage time", + "reset_cutting_blade_usage_time", + ), + ], +) @pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) -async def test_sync_clock( +async def test_button_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + entity_id: str, + name: str, + expected_command: str, ) -> None: - """Test sync clock button command.""" - entity_id = "button.test_mower_1_sync_clock" + """Test Automower button commands.""" + values[TEST_MOWER_ID].mower.is_error_confirmable = True await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) - assert state.name == "Test Mower 1 Sync clock" + assert state.name == name mock_automower_client.get_status.return_value = values @@ -103,11 +104,15 @@ async def test_sync_clock( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_automower_client.commands.set_datetime.assert_called_once_with(TEST_MOWER_ID) + + command_mock = getattr(mock_automower_client.commands, expected_command) + command_mock.assert_called_once_with(TEST_MOWER_ID) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == "2024-02-29T11:00:00+00:00" - mock_automower_client.commands.set_datetime.side_effect = ApiError("Test error") + command_mock.reset_mock() + command_mock.side_effect = ApiError("Test error") with pytest.raises( HomeAssistantError, match="Failed to send command: Test error", From bfae07135a7355b173d12ed1b8b655dd48fa24cc Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 4 Aug 2025 22:35:47 +0200 Subject: [PATCH 0911/1118] Bump python-airos to 0.2.4 (#149885) --- homeassistant/components/airos/config_flow.py | 18 +++--- homeassistant/components/airos/coordinator.py | 18 +++--- homeassistant/components/airos/manifest.json | 2 +- homeassistant/components/airos/sensor.py | 7 --- homeassistant/components/airos/strings.json | 7 --- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airos/snapshots/test_sensor.ambr | 58 ------------------- tests/components/airos/test_config_flow.py | 12 ++-- tests/components/airos/test_sensor.py | 12 ++-- 10 files changed, 35 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 287f54101c8..8df93c7b2c4 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -6,11 +6,11 @@ import logging from typing import Any from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import voluptuous as vol @@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN): airos_data = await airos_device.status() except ( - ConnectionSetupError, - DeviceConnectionError, + AirOSConnectionSetupError, + AirOSDeviceConnectionError, ): errors["base"] = "cannot_connect" - except (ConnectionAuthenticationError, DataMissingError): + except (AirOSConnectionAuthenticationError, AirOSDataMissingError): errors["base"] = "invalid_auth" - except KeyDataMissingError: + except AirOSKeyDataMissingError: errors["base"] = "key_data_missing" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 3f0f1a12380..2fe675ee76a 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -6,10 +6,10 @@ import logging from airos.airos8 import AirOS, AirOSData from airos.exceptions import ( - ConnectionAuthenticationError, - ConnectionSetupError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSConnectionSetupError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from homeassistant.config_entries import ConfigEntry @@ -47,18 +47,22 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]): try: await self.airos_device.login() return await self.airos_device.status() - except (ConnectionAuthenticationError,) as err: + except (AirOSConnectionAuthenticationError,) as err: _LOGGER.exception("Error authenticating with airOS device") raise ConfigEntryError( translation_domain=DOMAIN, translation_key="invalid_auth" ) from err - except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err: + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: _LOGGER.error("Error connecting to airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - except (DataMissingError,) as err: + except (AirOSDataMissingError,) as err: _LOGGER.error("Expected data not returned by airOS device: %s", err) raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index cb6119a6fa9..758902bbaa2 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.1"] + "requirements": ["airos==0.2.4"] } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 690bf21fc8e..4567261ba4d 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -69,13 +69,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( translation_key="wireless_essid", value_fn=lambda data: data.wireless.essid, ), - AirOSSensorEntityDescription( - key="wireless_mode", - translation_key="wireless_mode", - device_class=SensorDeviceClass.ENUM, - value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(), - options=WIRELESS_MODE_OPTIONS, - ), AirOSSensorEntityDescription( key="wireless_antenna_gain", translation_key="wireless_antenna_gain", diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 6823ba8520b..ff013862ee5 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -43,13 +43,6 @@ "wireless_essid": { "name": "Wireless SSID" }, - "wireless_mode": { - "name": "Wireless mode", - "state": { - "ap_ptp": "Access point", - "sta_ptp": "Station" - } - }, "wireless_antenna_gain": { "name": "Antenna gain" }, diff --git a/requirements_all.txt b/requirements_all.txt index 6e93cb6c595..f51da9d8c76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 945c16d3250..1a4f01dfcc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.1 +airos==0.2.4 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index a92d2dc35a2..e414d35beb2 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -439,64 +439,6 @@ 'state': '5500', }) # --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wireless mode', - 'platform': 'airos', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wireless_mode', - 'unique_id': '01:23:45:67:89:AB_wireless_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'NanoStation 5AC ap name Wireless mode', - 'options': list([ - 'ap_ptp', - 'sta_ptp', - ]), - }), - 'context': , - 'entity_id': 'sensor.nanostation_5ac_ap_name_wireless_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'ap_ptp', - }) -# --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_ssid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/airos/test_config_flow.py b/tests/components/airos/test_config_flow.py index 9d2a6376732..212c80dfc2b 100644 --- a/tests/components/airos/test_config_flow.py +++ b/tests/components/airos/test_config_flow.py @@ -4,9 +4,9 @@ from typing import Any from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DeviceConnectionError, - KeyDataMissingError, + AirOSConnectionAuthenticationError, + AirOSDeviceConnectionError, + AirOSKeyDataMissingError, ) import pytest @@ -78,9 +78,9 @@ async def test_form_duplicate_entry( @pytest.mark.parametrize( ("exception", "error"), [ - (ConnectionAuthenticationError, "invalid_auth"), - (DeviceConnectionError, "cannot_connect"), - (KeyDataMissingError, "key_data_missing"), + (AirOSConnectionAuthenticationError, "invalid_auth"), + (AirOSDeviceConnectionError, "cannot_connect"), + (AirOSKeyDataMissingError, "key_data_missing"), (Exception, "unknown"), ], ) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index 561741b1a2b..c9e675e7987 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -4,9 +4,9 @@ from datetime import timedelta from unittest.mock import AsyncMock from airos.exceptions import ( - ConnectionAuthenticationError, - DataMissingError, - DeviceConnectionError, + AirOSConnectionAuthenticationError, + AirOSDataMissingError, + AirOSDeviceConnectionError, ) from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,10 +39,10 @@ async def test_all_entities( @pytest.mark.parametrize( ("exception"), [ - ConnectionAuthenticationError, + AirOSConnectionAuthenticationError, TimeoutError, - DeviceConnectionError, - DataMissingError, + AirOSDeviceConnectionError, + AirOSDataMissingError, ], ) async def test_sensor_update_exception_handling( From 28236aa0235bcff9ea72375f3d558efc5f371dd8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 4 Aug 2025 23:03:38 +0200 Subject: [PATCH 0912/1118] Reolink disable entities by default (#149986) --- homeassistant/components/reolink/number.py | 7 +++++-- .../reolink/snapshots/test_diagnostics.ambr | 12 ++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index d0222b0cffb..da879194e88 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -116,6 +116,7 @@ NUMBER_ENTITIES = ( cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=1, native_max_value=100, @@ -407,8 +408,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_left", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -420,8 +421,8 @@ NUMBER_ENTITIES = ( key="auto_track_limit_right", cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", - mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, native_step=1, native_min_value=-1, native_max_value=2700, @@ -435,6 +436,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -451,6 +453,7 @@ NUMBER_ENTITIES = ( translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index c2b059d658b..99df90340d2 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -90,8 +90,8 @@ 'null': 5, }), 'GetAiCfg': dict({ - '0': 4, - 'null': 4, + '0': 2, + 'null': 2, }), 'GetAudioAlarm': dict({ '0': 1, @@ -177,10 +177,6 @@ '0': 2, 'null': 2, }), - 'GetPtzTraceSection': dict({ - '0': 2, - 'null': 2, - }), 'GetPush': dict({ '0': 1, 'null': 2, @@ -196,8 +192,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 3, - 'null': 3, + '0': 2, + 'null': 2, }), 'GetZoomFocus': dict({ '0': 2, From d48cc03be71222fd8bc34b721b76a315feac369b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 4 Aug 2025 16:36:24 -0500 Subject: [PATCH 0913/1118] Bump wyoming to 1.7.2 (#150007) --- homeassistant/components/wyoming/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 31adb17d7f5..39f5267006e 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.7.1"], + "requirements": ["wyoming==1.7.2"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f51da9d8c76..2b07456128b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3133,7 +3133,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a4f01dfcc7..750be1cb2be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2586,7 +2586,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.7.1 +wyoming==1.7.2 # homeassistant.components.xbox xbox-webapi==2.1.0 From 53c9c42148229acdf2a0ca30573b7d3f96508972 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:01:40 +0200 Subject: [PATCH 0914/1118] Use relative trigger keys (#149846) --- homeassistant/components/mqtt/icons.json | 2 +- homeassistant/components/mqtt/strings.json | 2 +- homeassistant/components/mqtt/triggers.yaml | 2 +- homeassistant/components/zwave_js/trigger.py | 4 +- .../components/zwave_js/triggers/event.py | 5 +- .../zwave_js/triggers/value_updated.py | 5 +- homeassistant/helpers/automation.py | 21 ++++++++ homeassistant/helpers/config_validation.py | 7 +++ homeassistant/helpers/trigger.py | 48 +++++++++++-------- script/hassfest/icons.py | 2 +- script/hassfest/translations.py | 2 +- script/hassfest/triggers.py | 2 +- .../components/websocket_api/test_commands.py | 4 +- tests/components/zwave_js/test_trigger.py | 8 ++-- tests/helpers/test_automation.py | 36 ++++++++++++++ tests/helpers/test_trigger.py | 26 +++++----- 16 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 homeassistant/helpers/automation.py create mode 100644 tests/helpers/test_automation.py diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 46a588a5667..1aa0902b77e 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -11,7 +11,7 @@ } }, "triggers": { - "mqtt": { + "_": { "trigger": "mdi:swap-horizontal" } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 0e248cfd2d2..15285165047 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1285,7 +1285,7 @@ } }, "triggers": { - "mqtt": { + "_": { "name": "MQTT", "description": "When a specific message is received on a given MQTT topic.", "description_configured": "When an MQTT message has been received", diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml index d3998674d58..0de44f4b39f 100644 --- a/homeassistant/components/mqtt/triggers.yaml +++ b/homeassistant/components/mqtt/triggers.yaml @@ -1,6 +1,6 @@ # Describes the format for MQTT triggers -mqtt: +_: fields: payload: example: "on" diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index e934faec70c..d25737ffd59 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -8,8 +8,8 @@ from homeassistant.helpers.trigger import Trigger from .triggers import event, value_updated TRIGGERS = { - event.PLATFORM_TYPE: event.EventTrigger, - value_updated.PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, + event.RELATIVE_PLATFORM_TYPE: event.EventTrigger, + value_updated.RELATIVE_PLATFORM_TYPE: value_updated.ValueUpdatedTrigger, } diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 52c24055052..a9e37a8efa2 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -34,8 +34,11 @@ from ..helpers import ( ) from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" def validate_non_node_event_source(obj: dict) -> dict: diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index a50053fa2db..abd231ea568 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -37,8 +37,11 @@ from ..const import ( from ..helpers import async_get_nodes_from_targets, get_device_id from .trigger_helpers import async_bypass_dynamic_config_validation +# Relative platform type should be +RELATIVE_PLATFORM_TYPE = f"{__name__.rsplit('.', maxsplit=1)[-1]}" + # Platform type should be . -PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" +PLATFORM_TYPE = f"{DOMAIN}.{RELATIVE_PLATFORM_TYPE}" ATTR_FROM = "from" ATTR_TO = "to" diff --git a/homeassistant/helpers/automation.py b/homeassistant/helpers/automation.py new file mode 100644 index 00000000000..52a0fc13255 --- /dev/null +++ b/homeassistant/helpers/automation.py @@ -0,0 +1,21 @@ +"""Helpers for automation.""" + + +def get_absolute_description_key(domain: str, key: str) -> str: + """Return the absolute description key.""" + if not key.startswith("_"): + return f"{domain}.{key}" + key = key[1:] # Remove leading underscore + if not key: + return domain + return key + + +def get_relative_description_key(domain: str, key: str) -> str: + """Return the relative description key.""" + platform, *subtype = key.split(".", 1) + if platform != domain: + return f"_{key}" + if not subtype: + return "_" + return subtype[0] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index da1c1c80619..c2ebddf8012 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -644,6 +644,13 @@ def slug(value: Any) -> str: raise vol.Invalid(f"invalid slug {value} (try {slg})") +def underscore_slug(value: Any) -> str: + """Validate value is a valid slug, possibly starting with an underscore.""" + if value.startswith("_"): + return f"_{slug(value[1:])}" + return slug(value) + + def schema_with_slug_keys( value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug ) -> Callable: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index de3f71c4834..e9c4a3d5b02 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -40,9 +40,9 @@ from homeassistant.loader import ( from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, selector +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .selector import TargetSelector from .template import Template @@ -100,7 +100,7 @@ def starts_with_dot(key: str) -> str: _TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _TRIGGER_SCHEMA), + cv.underscore_slug: vol.Any(None, _TRIGGER_SCHEMA), } ) @@ -139,6 +139,7 @@ async def _register_trigger_platform( if hasattr(platform, "async_get_triggers"): for trigger_key in await platform.async_get_triggers(hass): + trigger_key = get_absolute_description_key(integration_domain, trigger_key) hass.data[TRIGGERS][trigger_key] = integration_domain new_triggers.add(trigger_key) elif hasattr(platform, "async_validate_trigger_config") or hasattr( @@ -357,9 +358,8 @@ class PluggableAction: async def _async_get_trigger_platform( - hass: HomeAssistant, config: ConfigType -) -> TriggerProtocol: - trigger_key: str = config[CONF_PLATFORM] + hass: HomeAssistant, trigger_key: str +) -> tuple[str, TriggerProtocol]: platform_and_sub_type = trigger_key.split(".") platform = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) @@ -368,7 +368,7 @@ async def _async_get_trigger_platform( except IntegrationNotFound: raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") from None try: - return await integration.async_get_platform("trigger") + return platform, await integration.async_get_platform("trigger") except ImportError: raise vol.Invalid( f"Integration '{platform}' does not provide trigger support" @@ -381,11 +381,14 @@ async def async_validate_trigger_config( """Validate triggers.""" config = [] for conf in trigger_config: - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors.get(trigger_key)): + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") conf = await trigger.async_validate_trigger_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): @@ -471,7 +474,8 @@ async def async_initialize_triggers( if not enabled: continue - platform = await _async_get_trigger_platform(hass, conf) + trigger_key: str = conf[CONF_PLATFORM] + platform_domain, platform = await _async_get_trigger_platform(hass, trigger_key) trigger_id = conf.get(CONF_ID, f"{idx}") trigger_idx = f"{idx}" trigger_alias = conf.get(CONF_ALIAS) @@ -487,7 +491,10 @@ async def async_initialize_triggers( action_wrapper = _trigger_action_wrapper(hass, action, conf) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) - trigger = trigger_descriptors[conf[CONF_PLATFORM]](hass, conf) + relative_trigger_key = get_relative_description_key( + platform_domain, trigger_key + ) + trigger = trigger_descriptors[relative_trigger_key](hass, conf) coro = trigger.async_attach_trigger(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) @@ -525,11 +532,11 @@ async def async_initialize_triggers( return remove_triggers -def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_triggers_file(integration: Integration) -> dict[str, Any]: """Load triggers file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _TRIGGERS_SCHEMA( load_yaml_dict(str(integration.file_path / "triggers.yaml")) ), @@ -549,11 +556,14 @@ def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_triggers_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load trigger files for multiple integrations.""" return { - integration.domain: _load_triggers_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_triggers_file(integration).items() + } for integration in integrations } @@ -574,7 +584,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_triggers_descriptions: dict[str, JSON_TYPE] = {} + new_triggers_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new triggers get added # while we are loading the missing ones so we do not @@ -601,7 +611,7 @@ async def async_get_all_descriptions( if integrations: new_triggers_descriptions = await hass.async_add_executor_job( - _load_triggers_files, hass, integrations + _load_triggers_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -610,7 +620,7 @@ async def async_get_all_descriptions( domain = triggers[missing_trigger] if ( - yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_triggers_descriptions.get(domain, {}).get( missing_trigger ) ) is None: diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 79ad7eec5ff..ba6ac5e88c8 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -136,7 +136,7 @@ TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("trigger"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 974c932ae5c..76af88f8dec 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -450,7 +450,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("conversation"): { vol.Required("agent"): { diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 8efaab47050..7406e6f98ea 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -50,7 +50,7 @@ TRIGGER_SCHEMA = vol.Any( TRIGGERS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, trigger.starts_with_dot)): object, - cv.slug: TRIGGER_SCHEMA, + cv.underscore_slug: TRIGGER_SCHEMA, } ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b513a04a40b..263cd4a4ed8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -806,10 +806,10 @@ async def test_subscribe_triggers( ) -> None: """Test trigger_platforms/subscribe command.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ tag_trigger_descriptions = """ - tag: {} + _: {} """ def _load_yaml(fname, secrets=None): diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 02675544644..4186f1a778e 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -977,7 +977,7 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + await TRIGGERS["event"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.event", @@ -988,7 +988,7 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) with pytest.raises(vol.Invalid): - await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + await TRIGGERS["value_updated"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1026,7 +1026,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS[f"{DOMAIN}.value_updated"].async_validate_trigger_config( + assert await TRIGGERS["value_updated"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1036,7 +1036,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( }, ) - assert await TRIGGERS[f"{DOMAIN}.event"].async_validate_trigger_config( + assert await TRIGGERS["event"].async_validate_trigger_config( hass, { "platform": f"{DOMAIN}.event", diff --git a/tests/helpers/test_automation.py b/tests/helpers/test_automation.py new file mode 100644 index 00000000000..1cd9944aecf --- /dev/null +++ b/tests/helpers/test_automation.py @@ -0,0 +1,36 @@ +"""Test automation helpers.""" + +import pytest + +from homeassistant.helpers.automation import ( + get_absolute_description_key, + get_relative_description_key, +) + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_absolute_description_key(relative_key: str, absolute_key: str) -> None: + """Test absolute description key.""" + DOMAIN = "homeassistant" + assert get_absolute_description_key(DOMAIN, relative_key) == absolute_key + + +@pytest.mark.parametrize( + ("relative_key", "absolute_key"), + [ + ("turned_on", "homeassistant.turned_on"), + ("_", "homeassistant"), + ("_state", "state"), + ], +) +def test_relative_description_key(relative_key: str, absolute_key: str) -> None: + """Test relative description key.""" + DOMAIN = "homeassistant" + assert get_relative_description_key(DOMAIN, absolute_key) == relative_key diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 050420d0195..13441065691 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -50,7 +50,7 @@ async def test_trigger_subtype(hass: HomeAssistant) -> None: "homeassistant.helpers.trigger.async_get_integration", return_value=MagicMock(async_get_platform=AsyncMock()), ) as integration_mock: - await _async_get_trigger_platform(hass, {"platform": "test.subtype"}) + await _async_get_trigger_platform(hass, "test.subtype") assert integration_mock.call_args == call(hass, "test") @@ -493,8 +493,8 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[Trigger]]: return { - "test": MockTrigger1, - "test.trig_2": MockTrigger2, + "_": MockTrigger1, + "trig_2": MockTrigger2, } mock_integration(hass, MockModule("test")) @@ -534,7 +534,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: "sun_trigger_descriptions", [ """ - sun: + _: fields: event: example: sunrise @@ -551,7 +551,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: .anchor: &anchor - sunrise - sunset - sun: + _: fields: event: example: sunrise @@ -569,7 +569,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" tag_trigger_descriptions = """ - tag: + _: fields: entity: selector: @@ -607,7 +607,7 @@ async def test_async_get_all_descriptions( # Test we only load triggers.yaml for integrations with triggers, # system_health has no triggers - assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered( + assert proxy_load_triggers_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -615,7 +615,7 @@ async def test_async_get_all_descriptions( # system_health does not have triggers and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -650,7 +650,7 @@ async def test_async_get_all_descriptions( new_descriptions = await trigger.async_get_all_descriptions(hass) assert new_descriptions is not descriptions assert new_descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "event": { "example": "sunrise", @@ -666,7 +666,7 @@ async def test_async_get_all_descriptions( "offset": {"selector": {"time": {}}}, } }, - DOMAIN_TAG: { + "tag": { "fields": { "entity": { "selector": { @@ -736,7 +736,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -760,7 +760,7 @@ async def test_async_get_all_descriptions_with_bad_description( assert ( "Unable to parse triggers.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text @@ -787,7 +787,7 @@ async def test_subscribe_triggers( ) -> None: """Test trigger.async_subscribe_platform_events.""" sun_trigger_descriptions = """ - sun: {} + _: {} """ def _load_yaml(fname, secrets=None): From 68faa897adfe9236eb1da5ba0095a8139cf81324 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:48:47 +0200 Subject: [PATCH 0915/1118] Bump aioautomower to 2.1.2 (#150003) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index a0f25b1df4c..49eb364858f 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.1.1"] + "requirements": ["aioautomower==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b07456128b..1622ecf923a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 750be1cb2be..bf740da0e8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.1 +aioautomower==2.1.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 4c5cf028d7861e7a9082ffcc6f67b979a4f0cbba Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 5 Aug 2025 08:50:42 +0200 Subject: [PATCH 0916/1118] Fix Z-Wave duplicate provisioned device (#150008) --- homeassistant/components/zwave_js/__init__.py | 56 +++++++++------- tests/components/zwave_js/test_init.py | 64 ++++++++++++++++--- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 923cd776f92..af42f024e6a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -509,7 +509,7 @@ class ControllerEvents: ) ) - await self.async_check_preprovisioned_device(node) + await self.async_check_pre_provisioned_device(node) if node.is_controller_node: # Create a controller status sensor for each device @@ -637,8 +637,8 @@ class ControllerEvents: f"{DOMAIN}.identify_controller.{dev_id[1]}", ) - async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None: - """Check if the node was preprovisioned and update the device registry.""" + async def async_check_pre_provisioned_device(self, node: ZwaveNode) -> None: + """Check if the node was pre-provisioned and update the device registry.""" provisioning_entry = ( await self.driver_events.driver.controller.async_get_provisioning_entry( node.node_id @@ -648,29 +648,37 @@ class ControllerEvents: provisioning_entry and provisioning_entry.additional_properties and "device_id" in provisioning_entry.additional_properties - ): - preprovisioned_device = self.dev_reg.async_get( - provisioning_entry.additional_properties["device_id"] + and ( + pre_provisioned_device := self.dev_reg.async_get( + provisioning_entry.additional_properties["device_id"] + ) ) + and (dsk_identifier := (DOMAIN, f"provision_{provisioning_entry.dsk}")) + in pre_provisioned_device.identifiers + ): + driver = self.driver_events.driver + device_id = get_device_id(driver, node) + device_id_ext = get_device_id_ext(driver, node) + new_identifiers = pre_provisioned_device.identifiers.copy() + new_identifiers.remove(dsk_identifier) + new_identifiers.add(device_id) + if device_id_ext: + new_identifiers.add(device_id_ext) - if preprovisioned_device: - dsk = provisioning_entry.dsk - dsk_identifier = (DOMAIN, f"provision_{dsk}") - - # If the pre-provisioned device has the DSK identifier, remove it - if dsk_identifier in preprovisioned_device.identifiers: - driver = self.driver_events.driver - device_id = get_device_id(driver, node) - device_id_ext = get_device_id_ext(driver, node) - new_identifiers = preprovisioned_device.identifiers.copy() - new_identifiers.remove(dsk_identifier) - new_identifiers.add(device_id) - if device_id_ext: - new_identifiers.add(device_id_ext) - self.dev_reg.async_update_device( - preprovisioned_device.id, - new_identifiers=new_identifiers, - ) + if self.dev_reg.async_get_device(identifiers=new_identifiers): + # If a device entry is registered with the node ID based identifiers, + # just remove the device entry with the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + else: + # Add the node ID based identifiers to the device entry + # with the DSK identifier and remove the DSK identifier. + self.dev_reg.async_update_device( + pre_provisioned_device.id, + new_identifiers=new_identifiers, + ) async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 3c39868ff93..1aaa9013d87 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -497,17 +497,17 @@ async def test_on_node_added_ready( ) -async def test_on_node_added_preprovisioned( +async def test_check_pre_provisioned_device_update_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: - """Test node added event with a preprovisioned device.""" + """Test check pre-provisioned device that should update the device.""" dsk = "test" node = Node(client, deepcopy(multisensor_6_state)) - device = device_registry.async_get_or_create( + pre_provisioned_device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, f"provision_{dsk}")}, ) @@ -515,7 +515,7 @@ async def test_on_node_added_preprovisioned( { "dsk": dsk, "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], - "device_id": device.id, + "device_id": pre_provisioned_device.id, } ) with patch( @@ -526,14 +526,60 @@ async def test_on_node_added_preprovisioned( client.driver.controller.emit("node added", event) await hass.async_block_till_done() - device = device_registry.async_get(device.id) + device = device_registry.async_get(pre_provisioned_device.id) assert device assert device.identifiers == { get_device_id(client.driver, node), get_device_id_ext(client.driver, node), } assert device.sw_version == node.firmware_version - # There should only be the controller and the preprovisioned device + # There should only be the controller and the pre-provisioned device + assert len(device_registry.devices) == 2 + + +async def test_check_pre_provisioned_device_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, +) -> None: + """Test check pre-provisioned device that should remove the device.""" + dsk = "test" + driver = client.driver + node = Node(client, deepcopy(multisensor_6_state)) + pre_provisioned_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={(DOMAIN, f"provision_{dsk}")}, + ) + extended_identifier = get_device_id_ext(driver, node) + assert extended_identifier + existing_device = device_registry.async_get_or_create( + config_entry_id=integration.entry_id, + identifiers={ + get_device_id(driver, node), + extended_identifier, + }, + ) + provisioning_entry = ProvisioningEntry.from_dict( + { + "dsk": dsk, + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "device_id": pre_provisioned_device.id, + } + ) + with patch( + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry", + side_effect=lambda id: provisioning_entry if id == node.node_id else None, + ): + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + assert not device_registry.async_get(pre_provisioned_device.id) + assert device_registry.async_get(existing_device.id) + + # There should only be the controller and the existing device assert len(device_registry.devices) == 2 From ed2ced6c36192064cf90402b2dc3d0323bac7930 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:55:54 +0200 Subject: [PATCH 0917/1118] Fix zimi test RuntimeWarnings (#150017) --- tests/components/zimi/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/zimi/conftest.py b/tests/components/zimi/conftest.py index 44d898deffb..b26c2f89784 100644 --- a/tests/components/zimi/conftest.py +++ b/tests/components/zimi/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for Zimi component.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -19,6 +19,8 @@ def mock_api(): """Mock the API with defaults.""" with patch("homeassistant.components.zimi.async_connect_to_controller") as mock: mock_api = mock.return_value + mock_api.describe = MagicMock() + mock_api.disconnect = MagicMock() mock_api.connect.return_value = True mock_api.mac = INPUT_MAC mock_api.brand = API_INFO["brand"] From afee936c3dedca3d700022483f54e2ed54098400 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 5 Aug 2025 09:03:23 +0200 Subject: [PATCH 0918/1118] Update knx-frontend to 2025.8.4.154919 (#149991) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6a4565dde0e..f40fa028e88 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.7.23.50952" + "knx-frontend==2025.8.4.154919" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 1622ecf923a..e4049fe3850 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.4.154919 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf740da0e8d..fef79f6c3d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.7.23.50952 +knx-frontend==2025.8.4.154919 # homeassistant.components.konnected konnected==1.2.0 From 55c7c2f730280f8f27e58d521c4fedc4eb2853ee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:06:15 +0200 Subject: [PATCH 0919/1118] Redact terminal_id in Tuya fixture files (#149957) --- tests/components/tuya/fixtures/clkg_nhyj64w2.json | 2 +- tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json | 2 +- tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json | 2 +- tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json | 2 +- tests/components/tuya/fixtures/cwjwq_agwu93lr.json | 2 +- tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json | 2 +- tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json | 2 +- tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json | 2 +- tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json | 2 +- tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json | 3 +-- tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json | 2 +- tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json | 2 +- tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json | 2 +- tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json | 2 +- tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json | 2 +- tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json | 2 +- tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json | 2 +- tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json | 2 +- tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json | 2 +- tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json | 2 +- tests/components/tuya/fixtures/tyndj_pyakuuoc.json | 2 +- tests/components/tuya/fixtures/wk_aqoouq7x.json | 2 +- tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json | 2 +- tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json | 3 +-- tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json | 2 +- tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json | 2 +- 26 files changed, 26 insertions(+), 28 deletions(-) diff --git a/tests/components/tuya/fixtures/clkg_nhyj64w2.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json index 28e3248f8b5..0f64bae778f 100644 --- a/tests/components/tuya/fixtures/clkg_nhyj64w2.json +++ b/tests/components/tuya/fixtures/clkg_nhyj64w2.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1729466466688hgsTp2", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json index 8d7e744fb52..fb544fb7d5e 100644 --- a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json index 8a2fd881262..755b46fa397 100644 --- a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json +++ b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json index ff922f506c5..27d4e825ab1 100644 --- a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json +++ b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json index a4a9fc6aaff..84f76908338 100644 --- a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json +++ b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1750837476328i3TNXQ", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json index ec6f3ce5122..4bdd6f3167d 100644 --- a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json +++ b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1747045731408d0tb5M", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json index 0f5e5e5f241..695da229041 100644 --- a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json +++ b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751729689584Vh0VoL", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json index 9cd3c4ffd6f..27c3ae0c37f 100644 --- a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json +++ b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1742695000703Ozq34h", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json index 8e9a06cc9a9..2652399bdcb 100644 --- a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json +++ b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1733006572651YokbqV", + "terminal_id": "REDACTED", "mqtt_connected": null, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json index 28f2b8e8f46..ddfbce3ae11 100644 --- a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json +++ b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1732306182276g6jQLp", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb3e988f33c233290cfs3l", "name": "Colorful PIR Night Light", "category": "gyd", diff --git a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json index 63d9148afbf..a190161953b 100644 --- a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json +++ b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "1750526976566fMhqJs", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": true, diff --git a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json index 909022793ba..642ef968608 100644 --- a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json +++ b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "CENSORED", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json index 071596e8e6c..cb158a967b4 100644 --- a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json +++ b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json index 8fa2d7b0512..5b29fd0a191 100644 --- a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json +++ b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json index 1ae5e966de7..6cae732aedf 100644 --- a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json +++ b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1737479380414pasuj4", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json index c52086213fd..c538630c542 100644 --- a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json +++ b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1751921699759JsVujI", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json index caccb0b9234..efffe12a2f9 100644 --- a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json +++ b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1708196692712PHOeqy", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json index 58cbaedb0f1..24b4dbda594 100644 --- a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json +++ b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "17421891051898r7yM6", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json index dd95050e2bf..e57e9274690 100644 --- a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json +++ b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739471569144tcmeiO", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json index c139e79d19b..e7c79f3fb41 100644 --- a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json +++ b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1748383912663Y2lvlm", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json index 973cecabc0b..656c626c4fe 100644 --- a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1753247726209KOaaPc", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wk_aqoouq7x.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json index 2c162a1a514..900ae356f38 100644 --- a/tests/components/tuya/fixtures/wk_aqoouq7x.json +++ b/tests/components/tuya/fixtures/wk_aqoouq7x.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1749538552551GHfV17", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json index e96389ca215..002b0609464 100644 --- a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json +++ b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "xxxxxxxxxxxxxxxxxxx", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json index 06d07a4c506..2929872f4c1 100644 --- a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json +++ b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json @@ -1,10 +1,9 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "17150293164666xhFUk", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf316b8707b061f044th18", "name": "NP DownStairs North", "category": "wsdcg", diff --git a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json index f50aab00a26..a7ab15a4511 100644 --- a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json +++ b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "mock_terminal_id", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, diff --git a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json index 139cf814347..797ddba3587 100644 --- a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json +++ b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json @@ -1,6 +1,6 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1739198173271wpFacM", + "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, From 67c19087dd51d41a709aa86856d3975bb1873db3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Aug 2025 09:08:33 +0200 Subject: [PATCH 0920/1118] Bump deebot-client to 13.6.0 (#149983) --- 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 ceb7a1da9de..ddd464bdc6a 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.11", "deebot-client==13.5.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4049fe3850..5af92b3b745 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fef79f6c3d8..2220389d5ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -677,7 +677,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.5.0 +deebot-client==13.6.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 6b827dfc3304748ad1cdee0d6f1a3c926ea6cb9c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:52:29 +0200 Subject: [PATCH 0921/1118] Do not create Tuya fan entities without control (#149976) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/fan.py | 10 ++-- tests/components/tuya/__init__.py | 6 ++- .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 +++++++++ tests/components/tuya/snapshots/test_fan.ambr | 50 ------------------- 4 files changed, 34 insertions(+), 55 deletions(-) create mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90f4132cef0..056107d313f 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -45,6 +45,8 @@ TUYA_SUPPORT_TYPE = { "ks", } +_SWITCH_DP_CODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) + async def async_setup_entry( hass: HomeAssistant, @@ -60,7 +62,9 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device and device.category in TUYA_SUPPORT_TYPE: + if device.category in TUYA_SUPPORT_TYPE and any( + code in device.status for code in _SWITCH_DP_CODES + ): entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -90,9 +94,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True - ) + self._switch = self.find_dpcode(_SWITCH_DP_CODES, prefer_function=True) self._attr_preset_modes = [] if enum_type := self.find_dpcode( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index a66bd314185..742d017f285 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -43,7 +43,7 @@ DEVICE_MOCKS = { ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 - Platform.FAN, + # Platform.FAN, missing DPCodes in device status Platform.HUMIDIFIER, ], "cs_zibqa9dutqyaxym2": [ @@ -214,6 +214,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "fs_ibytpo6fpnugft1c": [ + # https://github.com/home-assistant/core/issues/135541 + # Platform.FAN, missing DPCodes in device status + ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..02b3808f84d --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "10706550a4e57c88b93a", + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 69eb1b467e9..52c4594f37b 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,56 +53,6 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.mock_device_id', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7dd761c9c313cd753281aa3e12b56bf2298d647a Mon Sep 17 00:00:00 2001 From: Grzegorz M <13075554+grzesjam@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:09:03 +0200 Subject: [PATCH 0922/1118] Bump icalendar from 6.1.0 to 6.3.1 for CalDav (#149990) --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index d0e0bd0b1d0..3b201c79e0c 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5af92b3b745..6c3b27b3fe5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1216,7 +1216,7 @@ ibmiotf==0.3.4 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2220389d5ec..5443891fc18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1056,7 +1056,7 @@ ibeacon-ble==1.2.0 ical==11.0.0 # homeassistant.components.caldav -icalendar==6.1.0 +icalendar==6.3.1 # homeassistant.components.ping icmplib==3.0 From 08ea64062900c9cfe3848bc5f73303580c8b1a85 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Aug 2025 11:13:32 +0200 Subject: [PATCH 0923/1118] Do not allow overriding users when uuid is duplicate (#149408) --- homeassistant/auth/auth_store.py | 3 +++ tests/auth/test_auth_store.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 1c2e8b0dfab..429aad09edb 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -120,6 +120,9 @@ class AuthStore: new_user = models.User(**kwargs) + while new_user.id in self._users: + new_user = models.User(**kwargs) + self._users[new_user.id] = new_user if credentials is None: diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 65bc35a5ff8..e5d3cf04a37 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -300,6 +300,20 @@ async def test_loading_does_not_write_right_away( assert hass_storage[auth_store.STORAGE_KEY] != {} +async def test_duplicate_uuid( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test we don't override user if we have a duplicate user ID.""" + hass_storage[auth_store.STORAGE_KEY] = MOCK_STORAGE_DATA + store = auth_store.AuthStore(hass) + await store.async_load() + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex_mock: + hex_mock.side_effect = ["user-id", "new-id"] + user = await store.async_create_user("Test User") + assert len(hex_mock.mock_calls) == 2 + assert user.id == "new-id" + + async def test_add_remove_user_affects_tokens( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 02a3c5be14dade751d1f8e4142473ea5eddd4eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 5 Aug 2025 11:19:03 +0200 Subject: [PATCH 0924/1118] Matter pump setpoint CurrentLevel limit (#149689) --- homeassistant/components/matter/number.py | 4 +++- tests/components/matter/fixtures/nodes/pump.json | 2 +- tests/components/matter/snapshots/test_number.ambr | 2 +- tests/components/matter/test_number.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4456496d52e..d2184891dc1 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -285,7 +285,9 @@ DISCOVERY_SCHEMAS = [ native_min_value=0.5, native_step=0.5, device_to_ha=( - lambda x: None if x is None else x / 2 # Matter range (1-200) + lambda x: None + if x is None + else min(x, 200) / 2 # Matter range (1-200, capped at 200) ), ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% mode=NumberMode.SLIDER, diff --git a/tests/components/matter/fixtures/nodes/pump.json b/tests/components/matter/fixtures/nodes/pump.json index e4afc0b4f33..6d74b3d1b89 100644 --- a/tests/components/matter/fixtures/nodes/pump.json +++ b/tests/components/matter/fixtures/nodes/pump.json @@ -203,7 +203,7 @@ "1/6/65528": [], "1/6/65529": [0, 1, 2], "1/6/65531": [0, 65532, 65533, 65528, 65529, 65531], - "1/8/0": 254, + "1/8/0": 200, "1/8/15": 0, "1/8/17": 0, "1/8/65532": 0, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index f7f467b4ed0..24a92799082 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -2189,7 +2189,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '127.0', + 'state': '100.0', }) # --- # name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index b59e6848f63..d35a889a436 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -172,7 +172,7 @@ async def test_pump_level( # CurrentLevel on LevelControl cluster state = hass.states.get("number.mock_pump_setpoint") assert state - assert state.state == "127.0" + assert state.state == "100.0" set_node_attribute(matter_node, 1, 8, 0, 100) await trigger_subscription_callback(hass, matter_client) From a6148b50cfa71eb103386e7a30afdceab909d3ad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 11:21:05 +0200 Subject: [PATCH 0925/1118] Add Tuya snapshots tests for button and vacuum platform (#149968) --- tests/components/tuya/__init__.py | 9 + .../tuya/fixtures/sd_lr33znaodtyarrrz.json | 476 ++++++++++++++++++ .../tuya/snapshots/test_button.ambr | 241 +++++++++ .../tuya/snapshots/test_number.ambr | 58 +++ .../tuya/snapshots/test_select.ambr | 120 +++++ .../tuya/snapshots/test_sensor.ambr | 467 +++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++ .../tuya/snapshots/test_vacuum.ambr | 64 +++ tests/components/tuya/test_button.py | 57 +++ tests/components/tuya/test_vacuum.py | 91 ++++ 10 files changed, 1631 insertions(+) create mode 100644 tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json create mode 100644 tests/components/tuya/snapshots/test_button.ambr create mode 100644 tests/components/tuya/snapshots/test_vacuum.ambr create mode 100644 tests/components/tuya/test_button.py create mode 100644 tests/components/tuya/test_vacuum.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 742d017f285..04fe034bb61 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -298,6 +298,15 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "sd_lr33znaodtyarrrz": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, + ], "sfkzq_o6dagifntoafakst": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json new file mode 100644 index 00000000000..77d94cb951b --- /dev/null +++ b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json @@ -0,0 +1,476 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfa951ca98fcf64fddqlmt", + "name": "V20", + "category": "sd", + "product_id": "lr33znaodtyarrrz", + "product_name": "V20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-23T16:37:02+00:00", + "create_time": "2025-03-23T16:37:02+00:00", + "update_time": "2025-03-23T16:37:02+00:00", + "function": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power_go": { + "type": "Boolean", + "value": {} + }, + "pause": { + "type": "Boolean", + "value": {} + }, + "switch_charge": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["smart", "zone", "pose", "part"] + } + }, + "status": { + "type": "Enum", + "value": { + "range": [ + "standby", + "zone_clean", + "part_clean", + "cleaning", + "paused", + "goto_pos", + "pos_arrived", + "pos_unarrive", + "goto_charge", + "charging", + "charge_done", + "sleep" + ] + } + }, + "clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 9999, + "scale": 0, + "step": 1 + } + }, + "electricity_left": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "suction": { + "type": "Enum", + "value": { + "range": ["closed", "gentle", "normal", "strong"] + } + }, + "cistern": { + "type": "Enum", + "value": { + "range": ["closed", "low", "middle", "high"] + } + }, + "seek": { + "type": "Boolean", + "value": {} + }, + "direction_control": { + "type": "Enum", + "value": { + "range": ["forward", "turn_left", "turn_right", "stop"] + } + }, + "reset_map": { + "type": "Boolean", + "value": {} + }, + "path_data": { + "type": "Raw", + "value": {} + }, + "command_trans": { + "type": "Raw", + "value": {} + }, + "request": { + "type": "Enum", + "value": { + "range": ["get_map", "get_path", "get_both"] + } + }, + "edge_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_edge_brush": { + "type": "Boolean", + "value": {} + }, + "roll_brush": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 18000, + "scale": 0, + "step": 1 + } + }, + "reset_roll_brush": { + "type": "Boolean", + "value": {} + }, + "filter": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_filter": { + "type": "Boolean", + "value": {} + }, + "duster_cloth": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 9000, + "scale": 0, + "step": 1 + } + }, + "reset_duster_cloth": { + "type": "Boolean", + "value": {} + }, + "switch_disturb": { + "type": "Boolean", + "value": {} + }, + "volume_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "break_clean": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "low_power", + "poweroff", + "wheel_trap", + "cannot_upgrade", + "collision_stuck", + "dust_station_full", + "tile_error", + "lidar_speed_err", + "lidar_cover", + "lidar_point_err", + "front_wall_dirty", + "psd_dirty", + "middle_sweep", + "side_sweep", + "fan_speed", + "dustbox_out", + "dustbox_full", + "no_dust_box", + "dustbox_fullout", + "trapped", + "pick_up", + "no_dust_water_box", + "water_box_empty", + "forbid_area", + "land_check", + "findcharge_fail", + "battery_err", + "kit_wheel", + "kit_lidar", + "kit_water_pump" + ] + } + }, + "total_clean_area": { + "type": "Integer", + "value": { + "unit": "㎡", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_count": { + "type": "Integer", + "value": { + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "total_clean_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 99999, + "scale": 0, + "step": 1 + } + }, + "device_timer": { + "type": "Raw", + "value": {} + }, + "disturb_time_set": { + "type": "Raw", + "value": {} + }, + "device_info": { + "type": "Raw", + "value": {} + }, + "voice_data": { + "type": "Raw", + "value": {} + }, + "language": { + "type": "Enum", + "value": { + "range": [ + "chinese_simplified", + "chinese_traditional", + "english", + "german", + "french", + "russian", + "spanish", + "korean", + "latin", + "portuguese", + "japanese", + "italian" + ] + } + }, + "customize_mode_switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power_go": false, + "pause": false, + "switch_charge": false, + "mode": "goto_charge", + "status": "charge_done", + "clean_time": 0, + "clean_area": 0, + "electricity_left": 100, + "suction": "strong", + "cistern": "middle", + "seek": false, + "direction_control": "forward", + "reset_map": false, + "path_data": "", + "command_trans": "qgABFxc=", + "request": "get_map", + "edge_brush": 8944, + "reset_edge_brush": false, + "roll_brush": 17948, + "reset_roll_brush": false, + "filter": 8956, + "reset_filter": false, + "duster_cloth": 9000, + "reset_duster_cloth": false, + "switch_disturb": false, + "volume_set": 95, + "break_clean": true, + "fault": 0, + "total_clean_area": 24, + "total_clean_count": 1, + "total_clean_time": 42, + "device_timer": "qgADMQEAMg==", + "disturb_time_set": "qgAIMwEWAAAIAABS", + "device_info": "eyJEZXZpY2VfU04iOiJJRlYyMDI1MDExNTAyMDIwMiIsIkZpcm13YXJlX1ZlcnNpb24iOiIxLjQuMyIsIklQIjoiMTkyLjE2OC4wLjIwMyIsIk1DVV9WZXJzaW9uIjoiMC4zMTQxLjEwNyIsIk1hYyI6IjM0OjE3OjM2OkU1OjAyOjc4IiwiTW9kdWxlX1VVSUQiOiJ6ZjExYjJmNzQ4Mzg5ZTY5ZDk4NiIsIlJTU0kiOiItNTAiLCJXaUZpX05hbWUiOiJGcnl0a2lfemFfZGFybW8ifQ==", + "voice_data": "qwAAAAAHNQAAAAADZJw=", + "language": "chinese_simplified", + "customize_mode_switch": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr new file mode 100644 index 00000000000..61b62e124e5 --- /dev/null +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-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': , + 'entity_id': 'button.v20_reset_duster_cloth', + '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': 'Reset duster cloth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_duster_cloth', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_duster_cloth', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset duster cloth', + }), + 'context': , + 'entity_id': 'button.v20_reset_duster_cloth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-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': , + 'entity_id': 'button.v20_reset_edge_brush', + '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': 'Reset edge brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_edge_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_edge_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset edge brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_edge_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-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': , + 'entity_id': 'button.v20_reset_filter', + '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': 'Reset filter', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filter', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_filter', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset filter', + }), + 'context': , + 'entity_id': 'button.v20_reset_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-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': , + 'entity_id': 'button.v20_reset_map', + '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': 'Reset map', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_map', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset map', + }), + 'context': , + 'entity_id': 'button.v20_reset_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-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': , + 'entity_id': 'button.v20_reset_roll_brush', + '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': 'Reset roll brush', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_roll_brush', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_roll_brush', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Reset roll brush', + }), + 'context': , + 'entity_id': 'button.v20_reset_roll_brush', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index fa9d7358314..b05b45cdd48 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -469,6 +469,64 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtvolume_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.v20_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95.0', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 84af76355d5..d2b3b3900e9 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -479,6 +479,126 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Mode', + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'context': , + 'entity_id': 'select.v20_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_water_tank_adjustment', + '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': 'Water tank adjustment', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_cistern', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtcistern', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Water tank adjustment', + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- # name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index d2cd0eb0676..061c6c58677 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2556,6 +2556,473 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-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.v20_cleaning_area', + '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': 'Cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-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.v20_cleaning_time', + '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': 'Cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-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.v20_duster_cloth_lifetime', + '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': 'Duster cloth lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duster_cloth_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtduster_cloth', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Duster cloth lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9000.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-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.v20_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtfilter', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Filter lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_filter_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8956.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-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.v20_rolling_brush_lifetime', + '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': 'Rolling brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rolling_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtroll_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Rolling brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17948.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-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.v20_side_brush_lifetime', + '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': 'Side brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtedge_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Side brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8944.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-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.v20_total_cleaning_area', + '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': 'Total cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-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.v20_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-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.v20_total_cleaning_times', + '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': 'Total cleaning times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_times', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning times', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index aa80ac08ee5..e5b41853703 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1406,6 +1406,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-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': , + 'entity_id': 'switch.v20_do_not_disturb', + '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': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtswitch_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.v20_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..0425cc45060 --- /dev/null +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.v20', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmt', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'strong', + 'fan_speed_list': list([ + 'gentle', + 'normal', + 'strong', + ]), + 'friendly_name': 'V20', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.v20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py new file mode 100644 index 00000000000..b8c6dda4afa --- /dev/null +++ b/tests/components/tuya/test_button.py @@ -0,0 +1,57 @@ +"""Test Tuya button platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py new file mode 100644 index 00000000000..1caf298f3c4 --- /dev/null +++ b/tests/components/tuya/test_vacuum.py @@ -0,0 +1,91 @@ +"""Test Tuya vacuum platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["sd_lr33znaodtyarrrz"], +) +async def test_return_home( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test return home service.""" + # Based on #141278 + entity_id = "vacuum.v20" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + { + "entity_id": entity_id, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "switch_charge", "value": True}] + ) From 803654223a26e1688f726e16b82bec3209b08f80 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:23:06 +0200 Subject: [PATCH 0926/1118] Revert "Do not create Tuya fan entities without control" (#150032) --- homeassistant/components/tuya/fan.py | 10 ++-- tests/components/tuya/__init__.py | 6 +-- .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 --------- tests/components/tuya/snapshots/test_fan.ambr | 50 +++++++++++++++++++ 4 files changed, 55 insertions(+), 34 deletions(-) delete mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 056107d313f..90f4132cef0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -45,8 +45,6 @@ TUYA_SUPPORT_TYPE = { "ks", } -_SWITCH_DP_CODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) - async def async_setup_entry( hass: HomeAssistant, @@ -62,9 +60,7 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device.category in TUYA_SUPPORT_TYPE and any( - code in device.status for code in _SWITCH_DP_CODES - ): + if device and device.category in TUYA_SUPPORT_TYPE: entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -94,7 +90,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode(_SWITCH_DP_CODES, prefer_function=True) + self._switch = self.find_dpcode( + (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + ) self._attr_preset_modes = [] if enum_type := self.find_dpcode( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 04fe034bb61..1498cd954d0 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -43,7 +43,7 @@ DEVICE_MOCKS = { ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 - # Platform.FAN, missing DPCodes in device status + Platform.FAN, Platform.HUMIDIFIER, ], "cs_zibqa9dutqyaxym2": [ @@ -214,10 +214,6 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], - "fs_ibytpo6fpnugft1c": [ - # https://github.com/home-assistant/core/issues/135541 - # Platform.FAN, missing DPCodes in device status - ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json deleted file mode 100644 index 02b3808f84d..00000000000 --- a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", - "mqtt_connected": true, - "disabled_by": null, - "disabled_polling": false, - "id": "10706550a4e57c88b93a", - "name": "Ventilador Cama", - "category": "fs", - "product_id": "ibytpo6fpnugft1c", - "product_name": "Tower bladeless fan ", - "online": true, - "sub": false, - "time_zone": "+01:00", - "active_time": "2025-01-10T18:47:46+00:00", - "create_time": "2025-01-10T18:47:46+00:00", - "update_time": "2025-01-10T18:47:46+00:00", - "function": {}, - "status_range": {}, - "status": {}, - "set_up": true, - "support_local": true -} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 52c4594f37b..69eb1b467e9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,6 +53,56 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 064a63fe1f4aa2070e522316a4a2979c8f3a3038 Mon Sep 17 00:00:00 2001 From: Nippey Date: Tue, 5 Aug 2025 12:54:40 +0200 Subject: [PATCH 0927/1118] Add support for Tuya "Bresser 7-in-1 Weatherstation" (#149498) --- homeassistant/components/tuya/const.py | 15 + homeassistant/components/tuya/sensor.py | 60 +++ homeassistant/components/tuya/strings.json | 12 + .../tuya/snapshots/test_sensor.ambr | 495 ++++++++++++++++++ 4 files changed, 582 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 87f80755e8b..e5a37d272ef 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -109,6 +109,7 @@ class DPCode(StrEnum): ANION = "anion" # Ionizer unit ARM_DOWN_PERCENT = "arm_down_percent" ARM_UP_PERCENT = "arm_up_percent" + ATMOSPHERIC_PRESSTURE = "atmospheric_pressture" # Typo is in Tuya API BASIC_ANTI_FLICKER = "basic_anti_flicker" BASIC_DEVICE_VOLUME = "basic_device_volume" BASIC_FLIP = "basic_flip" @@ -215,6 +216,10 @@ class DPCode(StrEnum): HUMIDITY = "humidity" # Humidity HUMIDITY_CURRENT = "humidity_current" # Current humidity HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_OUTDOOR = "humidity_outdoor" # Outdoor humidity + HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" # Outdoor humidity + HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" # Outdoor humidity + HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity IPC_WORK_MODE = "ipc_work_mode" @@ -360,6 +365,15 @@ class DPCode(StrEnum): TEMP_CURRENT_EXTERNAL = ( "temp_current_external" # Current external temperature in Celsius ) + TEMP_CURRENT_EXTERNAL_1 = ( + "temp_current_external_1" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_2 = ( + "temp_current_external_2" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_3 = ( + "temp_current_external_3" # Current external temperature in Celsius + ) TEMP_CURRENT_EXTERNAL_F = ( "temp_current_external_f" # Current external temperature in Fahrenheit ) @@ -405,6 +419,7 @@ class DPCode(StrEnum): WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" WINDSPEED = "windspeed" + WINDSPEED_AVG = "windspeed_avg" WIRELESS_BATTERYLOCK = "wireless_batterylock" WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index da7a57b1be2..aa53c8c6f02 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -846,6 +846,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_1, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_2, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL_3, + translation_key="indexed_temperature_external", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, translation_key="humidity", @@ -858,12 +879,51 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR, + translation_key="humidity_outdoor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_1, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "1"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_2, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "2"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_OUTDOOR_3, + translation_key="indexed_humidity_outdoor", + translation_placeholders={"index": "3"}, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ATMOSPHERIC_PRESSTURE, + translation_key="air_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.WINDSPEED_AVG, + translation_key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Gas Detector diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 97d623d7c21..ee9548cdef9 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -502,9 +502,21 @@ "temperature_external": { "name": "Probe temperature" }, + "indexed_temperature_external": { + "name": "Probe temperature channel {index}" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, + "humidity_outdoor": { + "name": "Outdoor humidity" + }, + "indexed_humidity_outdoor": { + "name": "Outdoor humidity channel {index}" + }, + "air_pressure": { + "name": "Air pressure" + }, "pm25": { "name": "[%key:component::sensor::entity_component::pm25::name%]" }, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 061c6c58677..42b395b5e34 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2025,6 +2025,62 @@ 'state': 'middle', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-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.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air pressure', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_pressure', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzatmospheric_pressture', + 'unit_of_measurement': 'hPa', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Air pressure', + 'state_class': , + 'unit_of_measurement': 'hPa', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1004.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2179,6 +2235,218 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_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': 'Outdoor humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-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.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor humidity channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_humidity_outdoor', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Outdoor humidity channel 3', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2235,6 +2503,174 @@ 'state': '-40.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_1', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.3', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_2', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-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.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature channel 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_3', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature channel 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2291,6 +2727,65 @@ 'state': '24.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-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.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + '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': 'Wind speed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzwindspeed_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 20fdec9e9ccb8bc44cde030de6c579df2ee7bed0 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:56:27 +0200 Subject: [PATCH 0928/1118] Reduce polling in Husqvarna Automower (#149255) Co-authored-by: Joost Lekkerkerker --- .../husqvarna_automower/coordinator.py | 60 ++++- .../husqvarna_automower/test_init.py | 211 +++++++++++++++++- 2 files changed, 267 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91adc8c75ec..a037df474cc 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import override @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerDictionary +from aioautomower.model import MowerDictionary, MowerStates from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time - +PONG_TIMEOUT = timedelta(seconds=90) +PING_INTERVAL = timedelta(seconds=10) +PING_TIMEOUT = timedelta(seconds=5) type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] @@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_devices_callbacks: list[Callable[[set[str]], None]] = [] self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = [] self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] + self.pong: datetime | None = None + self.websocket_alive: bool = False + self._watchdog_task: asyncio.Task | None = None @override @callback @@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): await self.api.connect() self.api.register_data_callback(self.handle_websocket_updates) self.ws_connected = True + + def start_watchdog() -> None: + if self._watchdog_task is not None and not self._watchdog_task.done(): + _LOGGER.debug("Cancelling previous watchdog task") + self._watchdog_task.cancel() + self._watchdog_task = self.config_entry.async_create_background_task( + self.hass, + self._pong_watchdog(), + "websocket_watchdog", + ) + + self.api.register_ws_ready_callback(start_watchdog) try: data = await self.api.get_status() except ApiError as err: @@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): mower_data.capabilities.work_areas for mower_data in self.data.values() ): self._async_add_remove_work_areas() + if ( + not self._should_poll() + and self.update_interval is not None + and self.websocket_alive + ): + _LOGGER.debug("All mowers inactive and websocket alive: stop polling") + self.update_interval = None + if self.update_interval is None and self._should_poll(): + _LOGGER.debug( + "Polling re-enabled via WebSocket: at least one mower active" + ) + self.update_interval = SCAN_INTERVAL + self.hass.async_create_task(self.async_request_refresh()) @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: @@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) + def _should_poll(self) -> bool: + """Return True if at least one mower is connected and at least one is not OFF.""" + return any(mower.metadata.connected for mower in self.data.values()) and any( + mower.mower.state != MowerStates.OFF for mower in self.data.values() + ) + + async def _pong_watchdog(self) -> None: + _LOGGER.debug("Watchdog started") + try: + while True: + _LOGGER.debug("Sending ping") + self.websocket_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", self.websocket_alive) + + await asyncio.sleep(60) + _LOGGER.debug("Websocket alive %s", self.websocket_alive) + if not self.websocket_alive: + _LOGGER.debug("No pong received → restart polling") + if self.update_interval is None: + self.update_interval = SCAN_INTERVAL + await self.async_request_refresh() + except asyncio.CancelledError: + _LOGGER.debug("Watchdog cancelled") + def _async_add_remove_devices(self) -> None: """Add new devices and remove orphaned devices from the registry.""" current_devices = set(self.data) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 81874cea8a7..a157380ab3c 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import Calendar, MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -484,3 +484,212 @@ async def test_add_and_remove_work_area( - ADDITIONAL_NUMBER_ENTITIES - ADDITIONAL_SENSOR_ENTITIES ) + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_dynamic_polling( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + websocket_values = deepcopy(values) + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # websocket is still active, and mowers are active -> polling required + mock_automower_client.get_status.reset_mock() + assert mock_automower_client.get_status.call_count == 0 + poll_values[TEST_MOWER_ID].metadata.connected = True + poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED + poll_values["1234"].metadata.connected = False + poll_values["1234"].mower.state = MowerStates.OFF + websocket_values = deepcopy(poll_values) + callback_holder["data_cb"](websocket_values) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + +@pytest.mark.parametrize( + ("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"), + [ + (True, MowerStates.OFF, False, MowerStates.OFF), # False + (False, MowerStates.PAUSED, False, MowerStates.OFF), # False + (False, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.PAUSED), # False + (True, MowerStates.OFF, True, MowerStates.OFF), # False + (False, MowerStates.OFF, False, MowerStates.OFF), # False + ], +) +async def test_websocket_watchdog( + hass: HomeAssistant, + mock_automower_client, + mock_config_entry, + freezer: FrozenDateTimeFactory, + entity_registry: er.EntityRegistry, + values: dict[str, MowerAttributes], + mower1_connected: bool, + mower1_state: MowerStates, + mower2_connected: bool, + mower2_state: MowerStates, +) -> None: + """Test that the ws_ready_callback triggers an attempt to start the Watchdog task. + + and that the pong callback stops polling when all mowers are inactive. + """ + poll_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["data_cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + callback_holder["ws_ready_cb"] = cb + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + + await setup_integration(hass, mock_config_entry) + + assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered" + callback_holder["ws_ready_cb"]() + + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 + + # websocket is still active, but mowers are inactive -> no polling required + poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected + poll_values[TEST_MOWER_ID].mower.state = mower1_state + poll_values["1234"].metadata.connected = mower2_connected + poll_values["1234"].mower.state = mower2_state + + mock_automower_client.get_status.return_value = poll_values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 3 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 4 + + # Simulate Pong loss and reset mock -> polling required + mock_automower_client.send_empty_message.return_value = False + mock_automower_client.get_status.reset_mock() + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 0 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 1 + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.get_status.call_count == 2 From 3a643572018f02549a981becb6a3e8eaaa767c05 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:22:45 +0200 Subject: [PATCH 0929/1118] Fix Tuya fan speeds with numeric values (#149971) --- homeassistant/components/tuya/fan.py | 4 +- tests/components/tuya/__init__.py | 20 ++ .../tuya/fixtures/cs_qhxmvae667uap4zh.json | 32 +++ .../tuya/fixtures/fs_g0ewlb1vmwqljzji.json | 134 +++++++++++ .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 23 ++ .../tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json | 86 +++++++ tests/components/tuya/snapshots/test_fan.ambr | 221 ++++++++++++++++++ .../tuya/snapshots/test_humidifier.ambr | 55 +++++ .../components/tuya/snapshots/test_light.ambr | 81 +++++++ .../tuya/snapshots/test_select.ambr | 63 +++++ .../tuya/snapshots/test_switch.ambr | 192 +++++++++++++++ 11 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json create mode 100644 tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json create mode 100644 tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json create mode 100644 tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 90f4132cef0..4c97b857fb7 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -267,7 +267,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity): return int(self._speed.remap_value_to(value, 1, 100)) if self._speeds is not None: - if (value := self.device.status.get(self._speeds.dpcode)) is None: + if ( + value := self.device.status.get(self._speeds.dpcode) + ) is None or value not in self._speeds.range: return None return ordered_list_item_to_percentage(self._speeds.range, value) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1498cd954d0..181f0a97763 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -41,6 +41,11 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_qhxmvae667uap4zh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.FAN, + Platform.HUMIDIFIER, + ], "cs_vmxuxszzjwp5smli": [ # https://github.com/home-assistant/core/issues/119865 Platform.FAN, @@ -214,6 +219,16 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "fs_g0ewlb1vmwqljzji": [ + # https://github.com/home-assistant/core/issues/141231 + Platform.FAN, + Platform.LIGHT, + Platform.SELECT, + ], + "fs_ibytpo6fpnugft1c": [ + # https://github.com/home-assistant/core/issues/135541 + Platform.FAN, + ], "gyd_lgekqfxdabipm3tn": [ # https://github.com/home-assistant/core/issues/133173 Platform.LIGHT, @@ -227,6 +242,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, ], + "kj_CAjWAxBUZt7QZHfz": [ + # https://github.com/home-assistant/core/issues/146023 + Platform.FAN, + Platform.SWITCH, + ], "kj_yrzylxax1qspdgpp": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, diff --git a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json new file mode 100644 index 00000000000..9b0b704e3de --- /dev/null +++ b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "28403630e8db84b7a963", + "name": "DryFix", + "category": "cs", + "product_id": "qhxmvae667uap4zh", + "product_name": "", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-04-03T13:10:02+00:00", + "create_time": "2024-04-03T13:10:02+00:00", + "update_time": "2024-04-03T13:10:02+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json new file mode 100644 index 00000000000..3aae03c904a --- /dev/null +++ b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json @@ -0,0 +1,134 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "XXX", + "name": "Ceiling Fan With Light", + "category": "fs", + "product_id": "g0ewlb1vmwqljzji", + "product_name": "Ceiling Fan With Light", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-22T22:57:04+00:00", + "create_time": "2025-03-22T22:57:04+00:00", + "update_time": "2025-03-22T22:57:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["normal", "sleep", "nature"] + } + }, + "fan_speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "fan_direction": { + "type": "Enum", + "value": { + "range": ["forward", "reverse"] + } + }, + "light": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "4h", "8h"] + } + } + }, + "status": { + "switch": true, + "mode": "normal", + "fan_speed": 1, + "fan_direction": "reverse", + "light": true, + "bright_value": 100, + "temp_value": 0, + "countdown_set": "off" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json new file mode 100644 index 00000000000..02b3808f84d --- /dev/null +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "10706550a4e57c88b93a", + "name": "Ventilador Cama", + "category": "fs", + "product_id": "ibytpo6fpnugft1c", + "product_name": "Tower bladeless fan ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-10T18:47:46+00:00", + "create_time": "2025-01-10T18:47:46+00:00", + "update_time": "2025-01-10T18:47:46+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json new file mode 100644 index 00000000000..5758fce2152 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "152027113c6105cce49c", + "name": "HL400", + "category": "kj", + "product_id": "CAjWAxBUZt7QZHfz", + "product_name": "air purifier", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-13T11:02:55+00:00", + "create_time": "2025-05-13T11:02:55+00:00", + "update_time": "2025-05-13T11:02:55+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "uv": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "speed": { + "type": "Enum", + "value": { + "range": ["1", "2", "3"] + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "lock": false, + "anion": true, + "speed": 3, + "uv": true, + "pm25": 45 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 69eb1b467e9..7532023860b 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -53,6 +53,56 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dryfix', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.28403630e8db84b7a963', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DryFix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -153,6 +203,177 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan_with_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.XXX', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'reverse', + 'friendly_name': 'Ceiling Fan With Light', + 'percentage': None, + 'percentage_step': 16.666666666666668, + 'preset_mode': 'normal', + 'preset_modes': list([ + 'normal', + 'sleep', + 'nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ventilador_cama', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.10706550a4e57c88b93a', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ventilador Cama', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ventilador_cama', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.hl400', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.152027113c6105cce49c', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400', + 'percentage': None, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.hl400', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 25bb1799dc8..33034e3f6e7 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -54,6 +54,61 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dryfix', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.28403630e8db84b7a963switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'DryFix', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b5dca58f8e7..4f2f22ddf2b 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -2022,6 +2022,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ceiling_fan_with_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.XXXlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Ceiling Fan With Light', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.ceiling_fan_with_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index d2b3b3900e9..0efd1e96840 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -414,6 +414,69 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.XXXcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ceiling Fan With Light Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'context': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index e5b41853703..175296d180e 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -970,6 +970,198 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-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': , + 'entity_id': 'switch.hl400_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.152027113c6105cce49clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Child lock', + }), + 'context': , + 'entity_id': 'switch.hl400_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-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': , + 'entity_id': 'switch.hl400_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.152027113c6105cce49canion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Ionizer', + }), + 'context': , + 'entity_id': 'switch.hl400_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-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.hl400_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.152027113c6105cce49cswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Power', + }), + 'context': , + 'entity_id': 'switch.hl400_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-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': , + 'entity_id': 'switch.hl400_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.152027113c6105cce49cuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 UV sterilization', + }), + 'context': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 31631cc882475863788350e68954f0399c8bed6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:40:01 +0200 Subject: [PATCH 0930/1118] Bump actions/ai-inference from 1.2.4 to 1.2.7 (#150038) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index fd7ed1a38a9..cbb82bc742e 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.7 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index eefc896bfcb..816d5cf8476 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.4 + uses: actions/ai-inference@v1.2.7 with: model: openai/gpt-4o-mini system-prompt: | From 9d8e253ad34d811b53ffad86a744f3f7b093b9ce Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 5 Aug 2025 14:15:08 +0100 Subject: [PATCH 0931/1118] Default to zero quantity on new todo items in Mealie (#150047) --- homeassistant/components/mealie/todo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index e31af281783..c701af2865c 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -130,6 +130,7 @@ class MealieShoppingListTodoListEntity(MealieEntity, TodoListEntity): list_id=self._shopping_list_id, note=item.summary.strip() if item.summary else item.summary, position=position, + quantity=0.0, ) try: await self.coordinator.client.add_shopping_item(new_shopping_item) From ffb2a693f48c8926498d17c42c1034481372212b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Aug 2025 15:22:21 +0200 Subject: [PATCH 0932/1118] Ignore vacuum entities that properly deprecate battery (#150043) --- homeassistant/components/vacuum/__init__.py | 14 ++++++++++++-- tests/components/template/test_vacuum.py | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 4b7a6907455..11db9108db3 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,6 +79,8 @@ DEFAULT_NAME = "Vacuum cleaner robot" _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") +_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) + class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" @@ -321,7 +323,11 @@ class StateVacuumEntity( Integrations should implement a sensor instead. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the {property} which has been deprecated." @@ -341,7 +347,11 @@ class StateVacuumEntity( Integrations should remove the battery supported feature when migrating battery level and icon to a sensor. """ - if self.platform: + if ( + self.platform + and self.platform.platform_name + not in _BATTERY_DEPRECATION_IGNORED_PLATFORMS + ): # Don't report usage until after entity added to hass, after init report_usage( f"is setting the battery supported feature which has been deprecated." diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index d0e6488e46e..8c2773956b2 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -603,7 +603,9 @@ async def test_battery_level_template( ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_battery_level_template_repair( - hass: HomeAssistant, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test battery_level template raises issue.""" # Ensure trigger entity templates are rendered @@ -618,6 +620,7 @@ async def test_battery_level_template_repair( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders["entity_name"] == TEST_OBJECT_ID assert issue.translation_placeholders["entity_id"] == TEST_ENTITY_ID + assert "Detected that integration 'template' is setting the" not in caplog.text @pytest.mark.parametrize( From f714388130384e7881aec3745e7f0de5237e6ac1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:25:58 +0200 Subject: [PATCH 0933/1118] Bump docker/login-action from 3.4.0 to 3.5.0 (#150034) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 82009751763..572a041fb7f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -330,14 +330,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.4.0 + uses: docker/login-action@v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 70c9b1f0953a9c9debf7484593b637d94504f9a4 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:31:02 +0200 Subject: [PATCH 0934/1118] Implement snapshot testing for Plugwise button platform (#149984) --- tests/components/plugwise/conftest.py | 24 ++++++++- .../plugwise/snapshots/test_button.ambr | 50 +++++++++++++++++++ tests/components/plugwise/test_button.py | 40 ++++++++------- 3 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_button.ambr diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index bc3de313a86..7120e0f87f0 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -130,6 +130,28 @@ def mock_smile_config_flow() -> Generator[MagicMock]: yield api +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms.""" + return [] + + +@pytest.fixture +async def setup_platform( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms, +) -> AsyncGenerator[None]: + """Set up one or all platforms.""" + + mock_config_entry.add_to_hass(hass) + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry + + @pytest.fixture def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" diff --git a/tests/components/plugwise/snapshots/test_button.ambr b/tests/components/plugwise/snapshots/test_button.ambr new file mode 100644 index 00000000000..900d85db527 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_button.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-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': , + 'entity_id': 'button.adam_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_button_snapshot[platforms0][button.adam_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Adam Reboot', + }), + 'context': , + 'entity_id': 'button.adam_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/plugwise/test_button.py b/tests/components/plugwise/test_button.py index 23003b3ffe6..8667e2ef893 100644 --- a/tests/components/plugwise/test_button.py +++ b/tests/components/plugwise/test_button.py @@ -2,32 +2,34 @@ from unittest.mock import MagicMock -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_adam_reboot_button( +@pytest.mark.parametrize("platforms", [(BUTTON_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_button_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam button snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +async def test_adam_press_reboot_button( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test creation of button entities.""" - state = hass.states.get("button.adam_reboot") - assert state - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - - registry = er.async_get(hass) - entry = registry.async_get("button.adam_reboot") - assert entry - assert entry.unique_id == "fe799307f1624099878210aa0b9f1475-reboot" - + """Test pressing of button entity.""" await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From 4e40e9bf74c24c6751c149ea13405d36a123a9f7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:56:03 +0200 Subject: [PATCH 0935/1118] Update mypy-dev to 1.18.0a4 (#150005) --- homeassistant/components/reolink/media_source.py | 4 +--- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 9c8c685d898..f716340e06e 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -422,9 +422,7 @@ class ReolinkVODMediaSource(MediaSource): file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: file_name += " " + " ".join( - str(trigger.name).title() - for trigger in file.triggers - if trigger != trigger.NONE + str(trigger.name).title() for trigger in file.triggers ) children.append( diff --git a/requirements_test.txt b/requirements_test.txt index 6c0fc02df58..592d4758340 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a3 +mypy-dev==1.18.0a4 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From 37510aa316bd46dbbd8f983fbfcec543eac7c704 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Aug 2025 16:01:47 +0200 Subject: [PATCH 0936/1118] Update frontend to 20250805.0 (#150049) --- 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 706940f5da7..7be7dd1def9 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==20250731.0"] + "requirements": ["home-assistant-frontend==20250805.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bca5e4648af..fe57522530f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.111.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6c3b27b3fe5..446d3f4c768 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5443891fc18..3c0564e4100 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.77 # homeassistant.components.frontend -home-assistant-frontend==20250731.0 +home-assistant-frontend==20250805.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From fe95f6e1c5ebf15da0987c1a69fc23f05cbf36cf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 5 Aug 2025 16:12:55 +0200 Subject: [PATCH 0937/1118] Improve downloader service (#150046) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/downloader/__init__.py | 3 + .../components/downloader/services.py | 38 +++++--- .../components/downloader/strings.json | 8 ++ tests/components/downloader/conftest.py | 94 +++++++++++++++++++ tests/components/downloader/test_init.py | 66 ++++++++++--- tests/components/downloader/test_services.py | 54 +++++++++++ 6 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 tests/components/downloader/conftest.py create mode 100644 tests/components/downloader/test_services.py diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index eb844ad8d3f..8b33c1d7ed3 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -18,6 +18,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # If path is relative, we assume relative to Home Assistant config dir if not os.path.isabs(download_path): download_path = hass.config.path(download_path) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DOWNLOAD_DIR: download_path} + ) if not await hass.async_add_executor_job(os.path.isdir, download_path): _LOGGER.error( diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index bb1b968dd99..0ccaee232d7 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -11,6 +11,7 @@ import requests import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -34,24 +35,33 @@ def download_file(service: ServiceCall) -> None: entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0] download_path = entry.data[CONF_DOWNLOAD_DIR] + url: str = service.data[ATTR_URL] + subdir: str | None = service.data.get(ATTR_SUBDIR) + target_filename: str | None = service.data.get(ATTR_FILENAME) + overwrite: bool = service.data[ATTR_OVERWRITE] + + if subdir: + # Check the path + try: + raise_if_invalid_path(subdir) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_invalid", + translation_placeholders={"subdir": subdir}, + ) from err + if os.path.isabs(subdir): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="subdir_not_relative", + translation_placeholders={"subdir": subdir}, + ) def do_download() -> None: """Download the file.""" + final_path = None + filename = target_filename try: - url = service.data[ATTR_URL] - - subdir = service.data.get(ATTR_SUBDIR) - - filename = service.data.get(ATTR_FILENAME) - - overwrite = service.data.get(ATTR_OVERWRITE) - - if subdir: - # Check the path - raise_if_invalid_path(subdir) - - final_path = None - req = requests.get(url, stream=True, timeout=10) if req.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index 7db7ea459d7..98c4a0a6c82 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -12,6 +12,14 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "exceptions": { + "subdir_invalid": { + "message": "Invalid subdirectory, got: {subdir}" + }, + "subdir_not_relative": { + "message": "Subdirectory must be relative, got: {subdir}" + } + }, "services": { "download_file": { "name": "Download file", diff --git a/tests/components/downloader/conftest.py b/tests/components/downloader/conftest.py new file mode 100644 index 00000000000..3bb63455ccc --- /dev/null +++ b/tests/components/downloader/conftest.py @@ -0,0 +1,94 @@ +"""Provide common fixtures for downloader tests.""" + +import asyncio +from pathlib import Path + +import pytest +from requests_mock import Mocker + +from homeassistant.components.downloader.const import ( + CONF_DOWNLOAD_DIR, + DOMAIN, + DOWNLOAD_COMPLETED_EVENT, + DOWNLOAD_FAILED_EVENT, +) +from homeassistant.core import Event, HomeAssistant, callback + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the downloader integration for testing.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + download_dir: Path, +) -> MockConfigEntry: + """Return a mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DOWNLOAD_DIR: str(download_dir)}, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def download_dir(tmp_path: Path) -> Path: + """Return a download directory.""" + return tmp_path + + +@pytest.fixture(autouse=True) +def mock_download_request( + requests_mock: Mocker, + download_url: str, +) -> None: + """Mock the download request.""" + requests_mock.get(download_url, text="{'one': 1}") + + +@pytest.fixture +def download_url() -> str: + """Return a mock download URL.""" + return "http://example.com/file.txt" + + +@pytest.fixture +def download_completed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download completion.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download is completed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_COMPLETED_EVENT}", download_set) + + return download_event + + +@pytest.fixture +def download_failed(hass: HomeAssistant) -> asyncio.Event: + """Return an asyncio event to wait for download failure.""" + download_event = asyncio.Event() + + @callback + def download_set(event: Event[dict[str, str]]) -> None: + """Set the event when download has failed.""" + download_event.set() + + hass.bus.async_listen_once(f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", download_set) + + return download_event diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index e74eb376b39..fe001838afe 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -1,6 +1,8 @@ """Tests for the downloader component init.""" -from unittest.mock import patch +from pathlib import Path + +import pytest from homeassistant.components.downloader.const import ( CONF_DOWNLOAD_DIR, @@ -13,17 +15,57 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_initialization(hass: HomeAssistant) -> None: - """Test the initialization of the downloader component.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_DOWNLOAD_DIR: "/test_dir", - }, - ) - config_entry.add_to_hass(hass) - with patch("os.path.isdir", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) +@pytest.fixture +def download_dir(tmp_path: Path, request: pytest.FixtureRequest) -> Path: + """Return a download directory.""" + if hasattr(request, "param"): + return tmp_path / request.param + return tmp_path + + +async def test_config_entry_setup( + hass: HomeAssistant, setup_integration: MockConfigEntry +) -> None: + """Test config entry setup.""" + config_entry = setup_integration assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_setup_relative_directory( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config entry setup with a relative download directory.""" + relative_directory = "downloads" + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_DOWNLOAD_DIR: relative_directory}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # The config entry will fail to set up since the directory does not exist. + # This is not relevant for this test. + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.data[CONF_DOWNLOAD_DIR] == hass.config.path( + relative_directory + ) + + +@pytest.mark.parametrize( + "download_dir", + [ + "not_existing_path", + ], + indirect=True, +) +async def test_config_entry_setup_not_existing_directory( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry setup without existing download directory.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert not hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/downloader/test_services.py b/tests/components/downloader/test_services.py new file mode 100644 index 00000000000..fbdc088021a --- /dev/null +++ b/tests/components/downloader/test_services.py @@ -0,0 +1,54 @@ +"""Test downloader services.""" + +import asyncio +from contextlib import AbstractContextManager, nullcontext as does_not_raise + +import pytest + +from homeassistant.components.downloader.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("subdir", "expected_result"), + [ + ("test", does_not_raise()), + ("test/path", does_not_raise()), + ("~test/path", pytest.raises(ServiceValidationError)), + ("~/../test/path", pytest.raises(ServiceValidationError)), + ("../test/path", pytest.raises(ServiceValidationError)), + (".../test/path", pytest.raises(ServiceValidationError)), + ("/test/path", pytest.raises(ServiceValidationError)), + ], +) +async def test_download_invalid_subdir( + hass: HomeAssistant, + download_completed: asyncio.Event, + download_failed: asyncio.Event, + download_url: str, + subdir: str, + expected_result: AbstractContextManager, +) -> None: + """Test service invalid subdirectory.""" + + async def call_service() -> None: + """Call the download service.""" + completed = hass.async_create_task(download_completed.wait()) + failed = hass.async_create_task(download_failed.wait()) + await hass.services.async_call( + DOMAIN, + "download_file", + { + "url": download_url, + "subdir": subdir, + "filename": "file.txt", + "overwrite": True, + }, + blocking=True, + ) + await asyncio.wait((completed, failed), return_when=asyncio.FIRST_COMPLETED) + + with expected_result: + await call_service() From 991c9008bdb5f7d431cc9c260c28c319e978a357 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Aug 2025 16:35:41 +0200 Subject: [PATCH 0938/1118] Change AI task strings (#150051) --- .../google_generative_ai_conversation/strings.json | 6 +++--- homeassistant/components/ollama/strings.json | 6 +++--- homeassistant/components/open_router/strings.json | 4 ++-- homeassistant/components/openai_conversation/strings.json | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 11e7c75c8ba..545436da590 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -123,10 +123,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4f3cb3c30c0..9ec03cef69a 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -58,10 +58,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "set_options": { "data": { diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index e73a65cd178..43a27a91959 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -52,9 +52,9 @@ } }, "initiate_flow": { - "user": "Add Generate data with AI service" + "user": "Add AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 4446eff2c9e..a1bf236f19b 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -73,10 +73,10 @@ }, "ai_task_data": { "initiate_flow": { - "user": "Add Generate data with AI service", - "reconfigure": "Reconfigure Generate data with AI service" + "user": "Add AI task", + "reconfigure": "Reconfigure AI task" }, - "entry_type": "Generate data with AI service", + "entry_type": "AI task", "step": { "init": { "data": { From 8c509b11b2b234c5c42df24df229d209ef045540 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:56:34 -0700 Subject: [PATCH 0939/1118] Fix template sensor uom string (#150057) --- homeassistant/components/template/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 96c8435c25c..200b323d377 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -759,7 +759,7 @@ "data_description": { "device_id": "[%key:component::template::common::device_id_description%]", "state": "[%key:component::template::config::step::sensor::data_description::state%]", - "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::state%]" + "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" }, "sections": { "advanced_options": { From 12dca4b1bfbd49eada906e424e970f2f2749ae47 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Aug 2025 18:58:22 +0200 Subject: [PATCH 0940/1118] Bump reolink-aio to 0.14.6 (#150055) --- homeassistant/components/reolink/diagnostics.py | 4 ++-- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- tests/components/reolink/snapshots/test_diagnostics.ambr | 2 +- tests/components/reolink/test_diagnostics.py | 2 ++ tests/components/reolink/test_sensor.py | 2 +- 9 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 48f6b709c23..912427fa881 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) - if (signal := api.wifi_signal(ch)) is not None: + if (signal := api.wifi_signal(ch)) is not None and api.wifi_connection(ch): IPC_cam[ch]["WiFi signal"] = signal chimes: dict[int, dict[str, Any]] = {} @@ -43,7 +43,7 @@ async def async_get_config_entry_diagnostics( "HTTP(S) port": api.port, "Baichuan port": api.baichuan.port, "Baichuan only": api.baichuan_only, - "WiFi connection": api.wifi_connection, + "WiFi connection": api.wifi_connection(), "WiFi signal": api.wifi_signal(), "RTMP enabled": api.rtmp_enabled, "RTSP enabled": api.rtsp_enabled, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index efd9f1121b6..4ad80dda807 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.5"] + "requirements": ["reolink-aio==0.14.6"] } diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index cd03f2b59b5..9b9a78c8ce7 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -148,7 +148,7 @@ HOST_SENSORS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, value=lambda api: api.wifi_signal(), - supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, + supported=lambda api: api.supported(None, "wifi") and api.wifi_connection(), ), ReolinkHostSensorEntityDescription( key="cpu_usage", diff --git a/requirements_all.txt b/requirements_all.txt index 446d3f4c768..dae69131cc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2666,7 +2666,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.5 +reolink-aio==0.14.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c0564e4100..35bc76f3cfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2212,7 +2212,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.5 +reolink-aio==0.14.6 # homeassistant.components.rflink rflink==0.0.67 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index fa4cac6fff3..48b024e0b10 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -128,7 +128,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False + host_mock.wifi_connection.return_value = False host_mock.wifi_signal.return_value = -45 host_mock.whiteled_mode_list.return_value = [] host_mock.post_recording_time_list.return_value = [] diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 99df90340d2..ca35d7eb70f 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -38,7 +38,7 @@ 'ONVIF enabled': True, 'RTMP enabled': True, 'RTSP enabled': True, - 'WiFi connection': False, + 'WiFi connection': True, 'WiFi signal': -45, 'abilities': dict({ 'abilityChn': list([ diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index b347bae9ec0..3e8ab4d0b2b 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -21,6 +21,8 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test Reolink diagnostics.""" + reolink_host.wifi_connection.return_value = True + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index b30f0c2a61a..9b32f70a9bd 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -21,7 +21,7 @@ async def test_sensors( ) -> None: """Test sensor entities.""" reolink_host.ptz_pan_position.return_value = 1200 - reolink_host.wifi_connection = True + reolink_host.wifi_connection.return_value = True reolink_host.wifi_signal.return_value = -55 reolink_host.hdd_list = [0] reolink_host.hdd_storage.return_value = 95 From 2b0cda0ad1236e8c0163158dbc7d06c433d37b79 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:46:03 +0200 Subject: [PATCH 0941/1118] Adjust condition and trigger method names (#150060) --- .../components/device_automation/condition.py | 4 ++-- homeassistant/components/sun/condition.py | 4 ++-- homeassistant/components/zone/condition.py | 4 ++-- homeassistant/components/zwave_js/triggers/event.py | 4 ++-- .../components/zwave_js/triggers/value_updated.py | 4 ++-- homeassistant/helpers/condition.py | 10 +++++----- homeassistant/helpers/trigger.py | 10 +++++----- tests/components/zwave_js/test_trigger.py | 8 ++++---- tests/helpers/test_condition.py | 6 +++--- tests/helpers/test_trigger.py | 6 +++--- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 5e2146a533c..426cc45a895 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -61,7 +61,7 @@ class DeviceCondition(Condition): self._hass = hass @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate device condition config.""" @@ -69,7 +69,7 @@ class DeviceCondition(Condition): hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION ) - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Test a device condition.""" platform = await async_get_device_automation_platform( self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index f48505b4993..15f3ea90c73 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -131,13 +131,13 @@ class SunCondition(Condition): self._hass = hass @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - async def async_condition_from_config(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with sun based condition.""" before = self._config.get("before") after = self._config.get("after") diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index 0fb30eeda9c..b0fe30b26fd 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -100,13 +100,13 @@ class ZoneCondition(Condition): self._config = config @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - async def async_condition_from_config(self) -> ConditionCheckerType: + async def async_get_checker(self) -> ConditionCheckerType: """Wrap action method with zone based condition.""" entity_ids = self._config.get(CONF_ENTITY_ID, []) zone_entity_ids = self._config.get(CONF_ZONE, []) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index a9e37a8efa2..77449af3e36 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -263,13 +263,13 @@ class EventTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index abd231ea568..f46592769cb 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -216,13 +216,13 @@ class ValueUpdatedTrigger(Trigger): self._hass = hass @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return await async_validate_trigger_config(hass, config) - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 3c6120f523f..5aa39e73166 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -199,14 +199,14 @@ class Condition(abc.ABC): @classmethod @abc.abstractmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @abc.abstractmethod - async def async_condition_from_config(self) -> ConditionCheckerType: - """Evaluate state based on configuration.""" + async def async_get_checker(self) -> ConditionCheckerType: + """Get the condition checker.""" class ConditionProtocol(Protocol): @@ -346,7 +346,7 @@ async def async_from_config( if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) condition_instance = condition_descriptors[condition](hass, config) - return await condition_instance.async_condition_from_config() + return await condition_instance.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition), None) @@ -974,7 +974,7 @@ async def async_validate_condition_config( condition_descriptors = await platform.async_get_conditions(hass) if not (condition_class := condition_descriptors.get(condition)): raise vol.Invalid(f"Invalid condition '{condition}' specified") - return await condition_class.async_validate_condition_config(hass, config) + return await condition_class.async_validate_config(hass, config) if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index e9c4a3d5b02..741fac3fcf7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -173,18 +173,18 @@ class Trigger(abc.ABC): @classmethod @abc.abstractmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @abc.abstractmethod - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: - """Attach a trigger.""" + """Attach the trigger.""" class TriggerProtocol(Protocol): @@ -390,7 +390,7 @@ async def async_validate_trigger_config( ) if not (trigger := trigger_descriptors.get(relative_trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") - conf = await trigger.async_validate_trigger_config(hass, conf) + conf = await trigger.async_validate_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): conf = await platform.async_validate_trigger_config(hass, conf) else: @@ -495,7 +495,7 @@ async def async_initialize_triggers( platform_domain, trigger_key ) trigger = trigger_descriptors[relative_trigger_key](hass, conf) - coro = trigger.async_attach_trigger(action_wrapper, info) + coro = trigger.async_attach(action_wrapper, info) else: coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 4186f1a778e..7b00a9d0eef 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -977,7 +977,7 @@ async def test_zwave_js_event_invalid_config_entry_id( async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: """Test invalid trigger configs.""" with pytest.raises(vol.Invalid): - await TRIGGERS["event"].async_validate_trigger_config( + await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", @@ -988,7 +988,7 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: ) with pytest.raises(vol.Invalid): - await TRIGGERS["value_updated"].async_validate_trigger_config( + await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1026,7 +1026,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( await hass.config_entries.async_unload(integration.entry_id) # Test full validation for both events - assert await TRIGGERS["value_updated"].async_validate_trigger_config( + assert await TRIGGERS["value_updated"].async_validate_config( hass, { "platform": f"{DOMAIN}.value_updated", @@ -1036,7 +1036,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( }, ) - assert await TRIGGERS["event"].async_validate_trigger_config( + assert await TRIGGERS["event"].async_validate_config( hass, { "platform": f"{DOMAIN}.event", diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 86aab3cb681..94e71696270 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2089,7 +2089,7 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: """Initialize condition.""" @classmethod - async def async_validate_condition_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @@ -2098,14 +2098,14 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: class MockCondition1(MockCondition): """Mock condition 1.""" - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: True class MockCondition2(MockCondition): """Mock condition 2.""" - async def async_condition_from_config(self) -> condition.ConditionCheckerType: + async def async_get_checker(self) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" return lambda hass, vars: False diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 13441065691..d5621a1ae61 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -461,7 +461,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: """Initialize trigger.""" @classmethod - async def async_validate_trigger_config( + async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" @@ -470,7 +470,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger1(MockTrigger): """Mock trigger 1.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, @@ -481,7 +481,7 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: class MockTrigger2(MockTrigger): """Mock trigger 2.""" - async def async_attach_trigger( + async def async_attach( self, action: TriggerActionType, trigger_info: TriggerInfo, From 7b45798e306b8af2846cf195551ba473f4ea6ee4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 5 Aug 2025 22:40:42 +0200 Subject: [PATCH 0942/1118] Remove matter vacuum battery level attribute (#150061) --- homeassistant/components/matter/vacuum.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 6ab687e060a..cf9f26adecb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -140,11 +140,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - # optional battery level - if VacuumEntityFeature.BATTERY & self._attr_supported_features: - self._attr_battery_level = self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ) # derive state from the run mode + operational state run_mode_raw: int = self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.CurrentMode @@ -188,11 +183,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): supported_features |= VacuumEntityFeature.STATE supported_features |= VacuumEntityFeature.STOP - # optional battery attribute = battery feature - if self.get_matter_attribute_value( - clusters.PowerSource.Attributes.BatPercentRemaining - ): - supported_features |= VacuumEntityFeature.BATTERY # optional identify cluster = locate feature (value must be not None or 0) if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE @@ -230,7 +220,6 @@ DISCOVERY_SCHEMAS = [ clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), - optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), From a24f027923a4d9621a162362c866ddde2a85f3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 5 Aug 2025 23:18:48 +0200 Subject: [PATCH 0943/1118] Add icon for esa_state in Matter integration (#149075) --- homeassistant/components/matter/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 2b9ca2cc3e2..475504d5aeb 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -99,6 +99,9 @@ "esa_opt_out_state": { "default": "mdi:home-lightning-bolt" }, + "esa_state": { + "default": "mdi:home-lightning-bolt" + }, "evse_state": { "default": "mdi:ev-station" }, From 977c0797aa23a305f5d31337bcfe43aee780044a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Aug 2025 23:36:48 +0200 Subject: [PATCH 0944/1118] Bump axis to v65 (#150065) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 9758af60178..1a125516130 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==64"], + "requirements": ["axis==65"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index dae69131cc6..9485d031233 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -573,7 +573,7 @@ av==13.1.0 # avion==0.10 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35bc76f3cfe..02bd6be8300 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -522,7 +522,7 @@ automower-ble==0.2.7 av==13.1.0 # homeassistant.components.axis -axis==64 +axis==65 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 From 445a7fc749a91ce23949cca6684e5e1f89e503a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 11:55:01 -1000 Subject: [PATCH 0945/1118] Bump yalexs to 8.11.1 (#150073) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e7af7d84942..51c5225b894 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index aa68009ac72..9086bb15575 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.2"] + "requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9485d031233..14f0370c88b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3167,7 +3167,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02bd6be8300..fa4d679188d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2617,7 +2617,7 @@ yalexs-ble==3.1.2 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.10.0 +yalexs==8.11.1 # homeassistant.components.yeelight yeelight==0.7.16 From 4f1b75e3b4db6009af08d309790a0d619e142bb4 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:56:27 -0400 Subject: [PATCH 0946/1118] Bump soco to 0.30.11 (#150072) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5bbfc33ae5b..79a50ef4732 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco", "sonos_websocket"], - "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.11", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 14f0370c88b..680eba380e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2805,7 +2805,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa4d679188d..e1eb560712e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2315,7 +2315,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.9 +soco==0.30.11 # homeassistant.components.solarlog solarlog_cli==0.4.0 From 8f328810bf38d4492a99e7a1a16fb95256d4a8c9 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:20:37 +0800 Subject: [PATCH 0947/1118] Bump pyswitchbot to 0.68.3 (#150080) --- 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 22168c21f97..6ed11acda08 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.2"] + "requirements": ["PySwitchbot==0.68.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 680eba380e9..eeb12e5deb3 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.68.2 +PySwitchbot==0.68.3 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1eb560712e..afe7026eb0e 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.68.2 +PySwitchbot==0.68.3 # homeassistant.components.syncthru PySyncThru==0.8.0 From d0ef1a1a8b9747bf06842d1028cc16aa6f7009b9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 6 Aug 2025 03:22:07 -0400 Subject: [PATCH 0948/1118] Bump ZHA to 0.0.66 (#150081) --- 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 facde4ead3a..38ce08aa782 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.65"], + "requirements": ["zha==0.0.66"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index eeb12e5deb3..9db07c964c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3203,7 +3203,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.65 +zha==0.0.66 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afe7026eb0e..3bd1a61322a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.65 +zha==0.0.66 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From 69faf38e862d42279745cb5e62b99260f20c2623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 6 Aug 2025 08:24:09 +0100 Subject: [PATCH 0949/1118] Bump hass-nabucasa from 0.111.0 to 0.111.1 (#150082) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0ef407b3628..76e55bc19b3 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.111.0"], + "requirements": ["hass-nabucasa==0.111.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fe57522530f..52d6a8a90b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.111.0 +hass-nabucasa==0.111.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250805.0 diff --git a/pyproject.toml b/pyproject.toml index 99ea68be900..0125d5b1bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.111.0", + "hass-nabucasa==0.111.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 90953842e20..af9a835e0d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.111.0 +hass-nabucasa==0.111.1 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9db07c964c8..6bf82fb6a67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.0 +hass-nabucasa==0.111.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bd1a61322a..f6bdc0b168d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.1 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.111.0 +hass-nabucasa==0.111.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 119d0a0170cbef6b7dd4a271f0b391375ece7478 Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Wed, 6 Aug 2025 09:28:44 +0200 Subject: [PATCH 0950/1118] Update knx-frontend to 2025.8.6.52906 (#150085) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f40fa028e88..f3013de4556 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.8.4.154919" + "knx-frontend==2025.8.6.52906" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6bf82fb6a67..b016a8e6a4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.4.154919 +knx-frontend==2025.8.6.52906 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6bdc0b168d..aa663460eee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.4.154919 +knx-frontend==2025.8.6.52906 # homeassistant.components.konnected konnected==1.2.0 From 28e19215ad7165fd2ae9ae39056be809a0fa4a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 09:46:02 +0200 Subject: [PATCH 0951/1118] Bump actions/ai-inference from 1.2.7 to 1.2.8 (#150083) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index cbb82bc742e..17777f576de 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.2.7 + uses: actions/ai-inference@v1.2.8 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 816d5cf8476..1aa51492c74 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.2.7 + uses: actions/ai-inference@v1.2.8 with: model: openai/gpt-4o-mini system-prompt: | From 400620399af5614da7dcae81f68cb582783aee03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Aug 2025 09:48:10 +0200 Subject: [PATCH 0952/1118] Bump actions/download-artifact from 4.3.0 to 5.0.0 (#150084) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 8 ++++---- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 572a041fb7f..2a667f83daa 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e96de66ac76..aca149bf020 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -970,7 +970,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: pytest_buckets - name: Compile English translations @@ -1336,7 +1336,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1486,7 +1486,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1511,7 +1511,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 8d9fca093de..3f0c0d578a9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.3.0 + uses: actions/download-artifact@v5.0.0 with: name: requirements_all_wheels From cba15ee43931d36516b527ee4393d8a924b55779 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Aug 2025 21:51:44 -1000 Subject: [PATCH 0953/1118] Bump habluetooth to 4.0.2 (#150078) Co-authored-by: Robert Resch --- homeassistant/components/bluetooth/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/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cd6aae91259..ce5d98f8edb 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==4.0.1" + "habluetooth==4.0.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52d6a8a90b2..b4731f66535 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==4.0.1 +habluetooth==4.0.2 hass-nabucasa==0.111.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b016a8e6a4a..dd1421d514c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.1 +habluetooth==4.0.2 # homeassistant.components.cloud hass-nabucasa==0.111.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa663460eee..f0cbd32749e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.1 +habluetooth==4.0.2 # homeassistant.components.cloud hass-nabucasa==0.111.1 From a83e4f5c6341b2b754cba346779bd1f26133a22f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 6 Aug 2025 10:07:36 +0200 Subject: [PATCH 0954/1118] Add missing translations for unhealthy Supervisor issues (#150036) --- homeassistant/components/hassio/issues.py | 6 +- homeassistant/components/hassio/strings.json | 68 +++++++++++--------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 16697659077..35f7f48481e 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -86,9 +86,11 @@ UNSUPPORTED_REASONS = { UNSUPPORTED_SKIP_REPAIR = {"privileged"} UNHEALTHY_REASONS = { "docker", - "supervisor", - "setup", + "duplicate_os_installation", + "oserror_bad_message", "privileged", + "setup", + "supervisor", "untrusted", } diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 2b87a7632a0..5df197bddcb 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -117,35 +117,43 @@ }, "unhealthy": { "title": "Unhealthy system - {reason}", - "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more." }, "unhealthy_docker": { "title": "Unhealthy system - Docker misconfigured", - "description": "System is currently unhealthy because Docker is configured incorrectly. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because Docker is configured incorrectly. For troubleshooting information, select Learn more." }, - "unhealthy_supervisor": { - "title": "Unhealthy system - Supervisor update failed", - "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. Use the link to learn more and how to fix this." + "unhealthy_duplicate_os_installation": { + "description": "System is currently unhealthy because it has detected multiple Home Assistant OS installations. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Duplicate Home Assistant OS installation" }, - "unhealthy_setup": { - "title": "Unhealthy system - Setup failed", - "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, use the link to learn more and how to fix this." + "unhealthy_oserror_bad_message": { + "description": "System is currently unhealthy because the operating system has reported an OS error: Bad message. For troubleshooting information, select Learn more.", + "title": "Unhealthy system - Operating System error: Bad message" }, "unhealthy_privileged": { "title": "Unhealthy system - Not privileged", - "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it does not have privileged access to the docker runtime. For troubleshooting information, select Learn more." + }, + "unhealthy_setup": { + "title": "Unhealthy system - Setup failed", + "description": "System is currently unhealthy because setup failed to complete. There are a number of reasons this can occur, For troubleshooting information, select Learn more." + }, + "unhealthy_supervisor": { + "title": "Unhealthy system - Supervisor update failed", + "description": "System is currently unhealthy because an attempt to update Supervisor to the latest version has failed. For troubleshooting information, select Learn more." }, "unhealthy_untrusted": { "title": "Unhealthy system - Untrusted code", - "description": "System is currently unhealthy because it has detected untrusted code or images in use. Use the link to learn more and how to fix this." + "description": "System is currently unhealthy because it has detected untrusted code or images in use. For troubleshooting information, select Learn more." }, "unsupported": { "title": "Unsupported system - {reason}", - "description": "System is unsupported due to {reason}. Use the link to learn more and how to fix this." + "description": "System is unsupported due to {reason}. For troubleshooting information, select Learn more." }, "unsupported_apparmor": { "title": "Unsupported system - AppArmor issues", - "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. Use the link to learn more and how to fix this." + "description": "System is unsupported because AppArmor is working incorrectly and add-ons are running in an unprotected and insecure way. For troubleshooting information, select Learn more." }, "unsupported_cgroup_version": { "title": "Unsupported system - CGroup version", @@ -153,23 +161,23 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", - "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more." }, "unsupported_dbus": { "title": "Unsupported system - D-Bus issues", - "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. Use the link to learn more and how to fix this." + "description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more." }, "unsupported_dns_server": { "title": "Unsupported system - DNS server issues", - "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. Use the link to learn more and how to fix this." + "description": "System is unsupported because the provided DNS server does not work correctly and the fallback DNS option has been disabled. For troubleshooting information, select Learn more." }, "unsupported_docker_configuration": { "title": "Unsupported system - Docker misconfigured", - "description": "System is unsupported because the Docker daemon is running in an unexpected way. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Docker daemon is running in an unexpected way. For troubleshooting information, select Learn more." }, "unsupported_docker_version": { "title": "Unsupported system - Docker version", @@ -177,15 +185,15 @@ }, "unsupported_job_conditions": { "title": "Unsupported system - Protections disabled", - "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. Use the link to learn more and how to fix this." + "description": "System is unsupported because one or more job conditions have been disabled which protect from unexpected failures and breakages. For troubleshooting information, select Learn more." }, "unsupported_lxc": { "title": "Unsupported system - LXC detected", - "description": "System is unsupported because it is being run in an LXC virtual machine. Use the link to learn more and how to fix this." + "description": "System is unsupported because it is being run in an LXC virtual machine. For troubleshooting information, select Learn more." }, "unsupported_network_manager": { "title": "Unsupported system - Network Manager issues", - "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Network Manager is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_os": { "title": "Unsupported system - Operating System", @@ -193,43 +201,43 @@ }, "unsupported_os_agent": { "title": "Unsupported system - OS-Agent issues", - "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because OS-Agent is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_restart_policy": { "title": "Unsupported system - Container restart policy", - "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. Use the link to learn more and how to fix this." + "description": "System is unsupported because a Docker container has a restart policy set which could cause issues on startup. For troubleshooting information, select Learn more." }, "unsupported_software": { "title": "Unsupported system - Unsupported software", - "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. Use the link to learn more and how to fix this." + "description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more." }, "unsupported_source_mods": { "title": "Unsupported system - Supervisor source modifications", - "description": "System is unsupported because Supervisor source code has been modified. Use the link to learn more and how to fix this." + "description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more." }, "unsupported_supervisor_version": { "title": "Unsupported system - Supervisor version", - "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. Use the link to learn more and how to fix this." + "description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more." }, "unsupported_systemd": { "title": "Unsupported system - Systemd issues", - "description": "System is unsupported because Systemd is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", - "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. For troubleshooting information, select Learn more." }, "unsupported_virtualization_image": { "title": "Unsupported system - Incorrect OS image for virtualization", - "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. For troubleshooting information, select Learn more." }, "unsupported_os_version": { "title": "Unsupported system - Home Assistant OS version", - "description": "System is unsupported because the Home Assistant OS version in use is not supported. Use the link to learn more and how to fix this." + "description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more." } }, "entity": { From 55abb6e5944e6f5a07ce5cffc031b50af2d446e2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Aug 2025 10:53:55 +0200 Subject: [PATCH 0955/1118] Fix hassio tests by only mocking supervisor id (#150093) --- tests/components/hassio/test_config.py | 36 ++++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/components/hassio/test_config.py b/tests/components/hassio/test_config.py index 4df8d2e81ac..4cdea02b087 100644 --- a/tests/components/hassio/test_config.py +++ b/tests/components/hassio/test_config.py @@ -1,13 +1,16 @@ """Test websocket API.""" +from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch -from uuid import UUID +from uuid import UUID, uuid4 import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User +from homeassistant.components.hassio import HASSIO_USER_NAME from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -98,7 +101,24 @@ def mock_all( ) -@pytest.mark.usefixtures("hassio_env") +@pytest.fixture +def mock_hassio_user_id() -> Generator[None]: + """Mock the HASSIO user ID for snapshot testing.""" + original_user_init = User.__init__ + + def mock_user_init(self, *args, **kwargs): + with patch("homeassistant.auth.models.uuid.uuid4") as mock_uuid: + if kwargs.get("name") == HASSIO_USER_NAME: + mock_uuid.return_value = UUID(bytes=b"very_very_random", version=4) + else: + mock_uuid.return_value = uuid4() + original_user_init(self, *args, **kwargs) + + with patch.object(User, "__init__", mock_user_init): + yield + + +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") @pytest.mark.parametrize( "storage_data", [ @@ -151,10 +171,7 @@ async def test_load_config_store( await hass.auth.async_create_refresh_token(user) await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN]) - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -162,7 +179,7 @@ async def test_load_config_store( assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot -@pytest.mark.usefixtures("hassio_env") +@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id") async def test_save_config_store( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -171,10 +188,7 @@ async def test_save_config_store( snapshot: SnapshotAssertion, ) -> None: """Test saving the config store.""" - with ( - patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0), - patch("uuid.uuid4", return_value=UUID(bytes=b"very_very_random", version=4)), - ): + with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0): assert await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() await hass.async_block_till_done() From fdb38ec8ec6a0bcb8cb92a51a6fa9cad0b82a67d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Aug 2025 10:58:52 +0200 Subject: [PATCH 0956/1118] Reduce Reolink fimware polling from 12h to 24h (#150095) --- homeassistant/components/reolink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 236e1707461..42a29ee6ef4 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -59,7 +59,7 @@ PLATFORMS = [ Platform.UPDATE, ] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) -FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) NUM_CRED_ERRORS = 3 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) From 13828f6713cda0a7667b005629e8b2ead0d3391f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:02:04 +0200 Subject: [PATCH 0957/1118] Remove tuya vacuum battery level attribute (#150086) --- homeassistant/components/tuya/sensor.py | 7 +++ homeassistant/components/tuya/vacuum.py | 16 +----- .../tuya/snapshots/test_sensor.ambr | 53 +++++++++++++++++++ .../tuya/snapshots/test_vacuum.ambr | 6 +-- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index aa53c8c6f02..5ca6e1d77a0 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -985,6 +985,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="rolling_brush_life", state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.ELECTRICITY_LEFT, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), ), # Smart Water Timer "sfkzq": ( diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index d61a624f027..6b4596ee053 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity -from .models import EnumTypeData, IntegerTypeData +from .models import EnumTypeData TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -77,7 +77,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): """Tuya Vacuum Device.""" _fan_speed: EnumTypeData | None = None - _battery_level: IntegerTypeData | None = None _attr_name = None def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: @@ -118,19 +117,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_fan_speed_list = enum_type.range self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED - if int_type := self.find_dpcode(DPCode.ELECTRICITY_LEFT, dptype=DPType.INTEGER): - self._attr_supported_features |= VacuumEntityFeature.BATTERY - self._battery_level = int_type - - @property - def battery_level(self) -> int | None: - """Return Tuya device state.""" - if self._battery_level is None or not ( - status := self.device.status.get(DPCode.ELECTRICITY_LEFT) - ): - return None - return round(self._battery_level.scale_value(status)) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 42b395b5e34..459bd80e0cc 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -3051,6 +3051,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_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.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtelectricity_left', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'V20 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.v20_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr index 0425cc45060..bc9ecd197d4 100644 --- a/tests/components/tuya/snapshots/test_vacuum.ambr +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -34,7 +34,7 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'tuya.bfa951ca98fcf64fddqlmt', 'unit_of_measurement': None, @@ -43,8 +43,6 @@ # name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-charging-100', - 'battery_level': 100, 'fan_speed': 'strong', 'fan_speed_list': list([ 'gentle', @@ -52,7 +50,7 @@ 'strong', ]), 'friendly_name': 'V20', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.v20', From 863e2074b67bbd765b276c07a4ee80bbd0b58e44 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:03:26 +0200 Subject: [PATCH 0958/1118] Add more switches to Tuya tdq category (#150090) --- homeassistant/components/tuya/switch.py | 12 + tests/components/tuya/__init__.py | 24 + .../tuya/fixtures/tdq_1aegphq4yfd50e6b.json | 139 +++++ .../tuya/fixtures/tdq_9htyiowaf5rtdhrv.json | 139 +++++ .../tuya/fixtures/tdq_nockvv2k39vbrxxk.json | 227 ++++++++ .../tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json | 169 ++++++ .../tuya/fixtures/tdq_uoa3mayicscacseb.json | 23 + .../tuya/snapshots/test_select.ambr | 177 +++++++ .../tuya/snapshots/test_sensor.ambr | 174 +++++++ .../tuya/snapshots/test_switch.ambr | 489 ++++++++++++++++++ 10 files changed, 1573 insertions(+) create mode 100644 tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json create mode 100644 tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json create mode 100644 tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json create mode 100644 tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json create mode 100644 tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f6d5df9af73..ecd7d9f4f44 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -758,6 +758,18 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_placeholders={"index": "4"}, device_class=SwitchDeviceClass.OUTLET, ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="indexed_switch", + translation_placeholders={"index": "5"}, + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="indexed_switch", + translation_placeholders={"index": "6"}, + device_class=SwitchDeviceClass.OUTLET, + ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 181f0a97763..2c2c67aa8db 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -356,11 +356,35 @@ DEVICE_MOCKS = { Platform.SIREN, Platform.SWITCH, ], + "tdq_1aegphq4yfd50e6b": [ + # https://github.com/home-assistant/core/issues/143209 + Platform.SELECT, + Platform.SWITCH, + ], + "tdq_9htyiowaf5rtdhrv": [ + # https://github.com/home-assistant/core/issues/143209 + Platform.SELECT, + Platform.SWITCH, + ], "tdq_cq1p0nt0a4rixnex": [ # https://github.com/home-assistant/core/issues/146845 Platform.SELECT, Platform.SWITCH, ], + "tdq_nockvv2k39vbrxxk": [ + # https://github.com/home-assistant/core/issues/145849 + Platform.SWITCH, + ], + "tdq_pu8uhxhwcp3tgoz7": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "tdq_uoa3mayicscacseb": [ + # https://github.com/home-assistant/core/issues/128911 + # SDK information is empty + ], "tyndj_pyakuuoc": [ # https://github.com/home-assistant/core/issues/149704 Platform.LIGHT, diff --git a/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json new file mode 100644 index 00000000000..fdfbae9fbbf --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfa008a4f82a56616c69uz", + "name": "jardin Fraises", + "category": "tdq", + "product_id": "1aegphq4yfd50e6b", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-13T12:26:55+00:00", + "create_time": "2024-09-13T12:26:55+00:00", + "update_time": "2024-09-13T12:26:55+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json new file mode 100644 index 00000000000..e3476118f20 --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json @@ -0,0 +1,139 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff35871a2f4430058vs8u", + "name": "Framboisiers", + "category": "tdq", + "product_id": "9htyiowaf5rtdhrv", + "product_name": "1-433", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-09-08T13:46:46+00:00", + "create_time": "2024-09-08T13:46:46+00:00", + "update_time": "2024-09-08T13:46:46+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC", + "switch_type": "button", + "remote_add": "", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json new file mode 100644 index 00000000000..1e40823b93d --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json @@ -0,0 +1,227 @@ +{ + "endpoint": "https://apigw.tuyain.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "d7ca553b5f406266350poc", + "name": "Seating side 6-ch Smart Switch ", + "category": "tdq", + "product_id": "nockvv2k39vbrxxk", + "product_name": "6 Switch Smart RetroFit Module", + "online": true, + "sub": false, + "time_zone": "+05:30", + "active_time": "2025-05-12T06:36:18+00:00", + "create_time": "2025-05-12T06:36:18+00:00", + "update_time": "2025-05-12T06:36:18+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "switch_5": { + "type": "Boolean", + "value": {} + }, + "switch_6": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_5": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_6": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false, + "switch_2": true, + "switch_3": false, + "switch_4": true, + "switch_5": false, + "switch_6": true, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_4": 0, + "countdown_5": 0, + "countdown_6": 0, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json new file mode 100644 index 00000000000..da26a133014 --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json @@ -0,0 +1,169 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf0dc19ab84dc3627ep2un", + "name": "Socket3", + "category": "tdq", + "product_id": "pu8uhxhwcp3tgoz7", + "product_name": "Smart Plug +", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-16T18:48:20+00:00", + "create_time": "2025-01-16T18:48:20+00:00", + "update_time": "2025-01-16T18:48:20+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kW·h", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2381, + "test_bit": 2, + "fault": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AAAC" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json new file mode 100644 index 00000000000..708764184ad --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json @@ -0,0 +1,23 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb3c90d87dac93d2bdxn3", + "name": "Living room left", + "category": "tdq", + "product_id": "uoa3mayicscacseb", + "product_name": "Curtain switch", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-10-30T16:55:40+00:00", + "create_time": "2024-10-30T16:55:40+00:00", + "update_time": "2024-10-30T16:55:40+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 0efd1e96840..db9964974bd 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1301,6 +1301,124 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bfa008a4f82a56616c69uzrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'jardin Fraises Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisiers_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bff35871a2f4430058vs8urelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisiers Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.framboisiers_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1360,3 +1478,62 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket3_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket3 Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.socket3_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 459bd80e0cc..e3ef0b4aa6a 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -3624,6 +3624,180 @@ 'state': '80.0', }) # --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Socket3 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Socket3 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.socket3_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_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.socket3_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Socket3 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 175296d180e..4c73d91c0c9 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2510,6 +2510,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-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.jardin_fraises_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bfa008a4f82a56616c69uzswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'jardin Fraises Switch 1', + }), + 'context': , + 'entity_id': 'switch.jardin_fraises_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-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.framboisiers_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bff35871a2f4430058vs8uswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisiers Switch 1', + }), + 'context': , + 'entity_id': 'switch.framboisiers_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2706,6 +2804,397 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_child_lock-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': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.d7ca553b5f406266350pocchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seating side 6-ch Smart Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-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.seating_side_6_ch_smart_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-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.seating_side_6_ch_smart_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-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.seating_side_6_ch_smart_switch_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 3', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-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.seating_side_6_ch_smart_switch_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 4', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-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.seating_side_6_ch_smart_switch_switch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 5', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-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.seating_side_6_ch_smart_switch_switch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 6', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-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.socket3_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket3 Switch 1', + }), + 'context': , + 'entity_id': 'switch.socket3_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0db23b0da6c32384a59a1032b147caee0678ad04 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:23:34 +0200 Subject: [PATCH 0959/1118] Add Tuya debug logging for new devices (#150091) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 106075e9314..e8aa6bded22 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -153,6 +153,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool # Register known device IDs device_registry = dr.async_get(hass) for device in manager.device_map.values(): + LOGGER.debug( + "Register device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, device.id)}, @@ -237,6 +244,14 @@ class DeviceListener(SharingDeviceListener): # Ensure the device isn't present stale self.hass.add_job(self.async_remove_device, device.id) + LOGGER.debug( + "Add device %s: %s (function: %s, status range: %s)", + device.id, + device.status, + device.function, + device.status_range, + ) + dispatcher_send(self.hass, TUYA_DISCOVERY_NEW, [device.id]) def remove_device(self, device_id: str) -> None: From 0aeff366bdb397983e7cde1ce69a54a057f2ecde Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 6 Aug 2025 02:32:42 -0700 Subject: [PATCH 0960/1118] Fix PG&E and Duquesne Light Company in Opower (#149658) Co-authored-by: Norbert Rittel --- .../components/opower/config_flow.py | 228 ++++++---- homeassistant/components/opower/const.py | 1 + .../components/opower/coordinator.py | 7 +- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/strings.json | 49 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opower/test_config_flow.py | 408 +++++++++++++++--- 8 files changed, 544 insertions(+), 155 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index e7f2534e1ad..b66c4c6870e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -9,6 +9,8 @@ from typing import Any from opower import ( CannotConnect, InvalidAuth, + MfaChallenge, + MfaHandlerBase, Opower, create_cookie_jar, get_supported_utility_names, @@ -16,49 +18,34 @@ from opower import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import VolDictType -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) +CONF_MFA_CODE = "mfa_code" +CONF_MFA_METHOD = "mfa_method" async def _validate_login( - hass: HomeAssistant, login_data: dict[str, str] -) -> dict[str, str]: - """Validate login data and return any errors.""" + hass: HomeAssistant, + data: Mapping[str, Any], +) -> None: + """Validate login data and raise exceptions on failure.""" api = Opower( async_create_clientsession(hass, cookie_jar=create_cookie_jar()), - login_data[CONF_UTILITY], - login_data[CONF_USERNAME], - login_data[CONF_PASSWORD], - login_data.get(CONF_TOTP_SECRET), + data[CONF_UTILITY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_TOTP_SECRET), + data.get(CONF_LOGIN_DATA), ) - errors: dict[str, str] = {} - try: - await api.async_login() - except InvalidAuth: - _LOGGER.exception( - "Invalid auth when connecting to %s", login_data[CONF_UTILITY] - ) - errors["base"] = "invalid_auth" - except CannotConnect: - _LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY]) - errors["base"] = "cannot_connect" - return errors + await api.async_login() class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): @@ -68,81 +55,147 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new OpowerConfigFlow.""" - self.utility_info: dict[str, Any] | None = None + self._data: dict[str, Any] = {} + self.mfa_handler: MfaHandlerBase | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors: dict[str, str] = {} + """Handle the initial step (select utility).""" if user_input is not None: - self._async_abort_entries_match( - { - CONF_UTILITY: user_input[CONF_UTILITY], - CONF_USERNAME: user_input[CONF_USERNAME], - } - ) - if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): - self.utility_info = user_input - return await self.async_step_mfa() + self._data[CONF_UTILITY] = user_input[CONF_UTILITY] + return await self.async_step_credentials() - errors = await _validate_login(self.hass, user_input) - if not errors: - return self._async_create_opower_entry(user_input) - else: - user_input = {} - user_input.pop(CONF_PASSWORD, None) return self.async_show_form( step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())} + ), + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle credentials step.""" + errors: dict[str, str] = {} + utility = select_utility(self._data[CONF_UTILITY]) + + if user_input is not None: + self._data.update(user_input) + + self._async_abort_entries_match( + { + CONF_UTILITY: self._data[CONF_UTILITY], + CONF_USERNAME: self._data[CONF_USERNAME], + } + ) + + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self._async_create_opower_entry(self._data) + + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + + return self.async_show_form( + step_id="credentials", data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, user_input + vol.Schema(schema_dict), user_input ), errors=errors, ) - async def async_step_mfa( + async def async_step_mfa_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle MFA step.""" - assert self.utility_info is not None + """Handle MFA options step.""" + errors: dict[str, str] = {} + assert self.mfa_handler is not None + + if user_input is not None: + method = user_input[CONF_MFA_METHOD] + try: + await self.mfa_handler.async_select_mfa_option(method) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return await self.async_step_mfa_code() + + mfa_options = await self.mfa_handler.async_get_mfa_options() + if not mfa_options: + return await self.async_step_mfa_code() + return self.async_show_form( + step_id="mfa_options", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}), + user_input, + ), + errors=errors, + ) + + async def async_step_mfa_code( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle MFA code submission step.""" + assert self.mfa_handler is not None errors: dict[str, str] = {} if user_input is not None: - data = {**self.utility_info, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self._async_create_opower_entry(data) - - if errors: - schema = { - vol.Required( - CONF_USERNAME, default=self.utility_info[CONF_USERNAME] - ): str, - vol.Required(CONF_PASSWORD): str, - } - else: - schema = {} - - schema[vol.Required(CONF_TOTP_SECRET)] = str + code = user_input[CONF_MFA_CODE] + try: + login_data = await self.mfa_handler.async_submit_mfa_code(code) + except InvalidAuth: + errors["base"] = "invalid_mfa_code" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self._data[CONF_LOGIN_DATA] = login_data + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._data + ) + return self._async_create_opower_entry(self._data) return self.async_show_form( - step_id="mfa", - data_schema=vol.Schema(schema), + step_id="mfa_code", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input + ), errors=errors, ) @callback - def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + def _async_create_opower_entry( + self, data: dict[str, Any], **kwargs: Any + ) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", data=data, + **kwargs, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - return await self.async_step_reauth_confirm() + reauth_entry = self._get_reauth_entry() + self._data = dict(reauth_entry.data) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: reauth_entry.title}, + ) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None @@ -150,21 +203,34 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() - if user_input is not None: - data = {**reauth_entry.data, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self.async_update_reload_and_abort(reauth_entry, data=data) - schema: VolDictType = { - vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME], + if user_input is not None: + self._data.update(user_input) + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort(reauth_entry, data=self._data) + + utility = select_utility(self._data[CONF_UTILITY]) + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa(): - schema[vol.Optional(CONF_TOTP_SECRET)] = str + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), self._data + ), errors=errors, description_placeholders={CONF_NAME: reauth_entry.title}, ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py index c07d41bbdcf..5da50b2b06f 100644 --- a/homeassistant/components/opower/const.py +++ b/homeassistant/components/opower/const.py @@ -4,3 +4,4 @@ DOMAIN = "opower" CONF_UTILITY = "utility" CONF_TOTP_SECRET = "totp_secret" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 189fa185cd1..e6fbbee0bb6 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -14,7 +14,7 @@ from opower import ( ReadResolution, create_cookie_jar, ) -from opower.exceptions import ApiException, CannotConnect, InvalidAuth +from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -36,7 +36,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], config_entry.data.get(CONF_TOTP_SECRET), + config_entry.data.get(CONF_LOGIN_DATA), ) @callback @@ -90,7 +91,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # Given the infrequent updating (every 12h) # assume previous session has expired and re-login. await self.api.async_login() - except InvalidAuth as err: + except (InvalidAuth, MfaChallenge) as err: _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err except CannotConnect as err: diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 4e88c5a68cc..a10c5b2d15d 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.12.4"] + "requirements": ["opower==0.15.1"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 8d8cecff905..5bb22699220 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -3,27 +3,43 @@ "step": { "user": { "data": { - "utility": "Utility name", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "utility": "Utility name" }, "data_description": { - "utility": "The name of your utility provider", - "username": "The username for your utility account", - "password": "The password for your utility account" + "utility": "The name of your utility provider" } }, - "mfa": { - "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "credentials": { + "title": "Enter Credentials", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "totp_secret": "TOTP secret" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "The username for your utility account", + "password": "The password for your utility account", + "totp_secret": "This is not a 6-digit code. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation." + } + }, + "mfa_options": { + "title": "Multi-factor authentication", + "description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.", + "data": { + "mfa_method": "MFA method" + }, + "data_description": { + "mfa_method": "How to receive your security code" + } + }, + "mfa_code": { + "title": "Enter security code", + "description": "A security code has been sent via your selected method. Please enter it below to complete login.", + "data": { + "mfa_code": "Security code" + }, + "data_description": { + "mfa_code": "Typically a 6-digit code" } }, "reauth_confirm": { @@ -31,18 +47,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" + "totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "[%key:component::opower::config::step::credentials::data_description::username%]", + "password": "[%key:component::opower::config::step::credentials::data_description::password%]", + "totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "The security code is incorrect. Please try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/requirements_all.txt b/requirements_all.txt index dd1421d514c..eb2916a4fb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1628,7 +1628,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0cbd32749e..7cc3f6b67ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1384,7 +1384,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index c9edfc6808f..4e5c3457fa6 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from opower import CannotConnect, InvalidAuth +from opower import CannotConnect, InvalidAuth, MfaChallenge import pytest from homeassistant import config_entries @@ -43,24 +43,32 @@ async def test_form( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" + assert result3["data"] == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", @@ -69,33 +77,33 @@ async def test_form( assert mock_login.call_count == 1 -async def test_form_with_mfa( +async def test_form_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test we can configure a utility that accepts a TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { + "username": "test-username", + "password": "test-password", "totp_secret": "test-totp", }, ) @@ -112,43 +120,42 @@ async def test_form_with_mfa( assert mock_login.call_count == 1 -async def test_form_with_mfa_bad_secret( +async def test_form_with_invalid_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test MFA asks for password again when validation fails.""" + """Test we handle an invalid TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter invalid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", side_effect=InvalidAuth, - ) as mock_login: + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "totp_secret": "test-totp", + "username": "test-username", + "password": "test-password", + "totp_secret": "bad-totp", }, ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "base": "invalid_auth", - } + assert result3["errors"] == {"base": "invalid_auth"} + assert result3["step_id"] == "credentials" + # Enter valid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -157,7 +164,7 @@ async def test_form_with_mfa_bad_secret( { "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", }, ) @@ -167,26 +174,195 @@ async def test_form_with_mfa_bad_secret( "utility": "Consolidated Edison (ConEd)", "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", } assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 +async def test_form_with_mfa_challenge( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow, including error recovery.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. Handle the MFA options step, starting with a connection error + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + # Test CannotConnect on selecting MFA method + mock_mfa_handler.async_select_mfa_option.side_effect = CannotConnect + result_mfa_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Email") + assert result_mfa_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_connect_fail["step_id"] == "mfa_options" + assert result_mfa_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry selecting MFA method successfully + mock_mfa_handler.async_select_mfa_option.side_effect = None + result_mfa_select_ok = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + assert mock_mfa_handler.async_select_mfa_option.call_count == 2 + assert result_mfa_select_ok["type"] is FlowResultType.FORM + assert result_mfa_select_ok["step_id"] == "mfa_code" + + # 4. Handle the MFA code step, testing multiple failure scenarios + # Test InvalidAuth on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = InvalidAuth + result_mfa_invalid_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "bad-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("bad-code") + assert result_mfa_invalid_code["type"] is FlowResultType.FORM + assert result_mfa_invalid_code["step_id"] == "mfa_code" + assert result_mfa_invalid_code["errors"] == {"base": "invalid_mfa_code"} + + # Test CannotConnect on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = CannotConnect + result_mfa_code_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 2 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + assert result_mfa_code_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_code_connect_fail["step_id"] == "mfa_code" + assert result_mfa_code_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry submitting code successfully + mock_mfa_handler.async_submit_mfa_code.side_effect = None + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 3 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 5. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_mfa_challenge_but_no_mfa_options( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow when there are no MFA options.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = {} + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. No MFA options. Handle the MFA code step + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_code" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 4. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.parametrize( ("api_exception", "expected_error"), [ - (InvalidAuth(), "invalid_auth"), - (CannotConnect(), "cannot_connect"), + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), ], ) async def test_form_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error + recorder_mock: Recorder, + hass: HomeAssistant, + api_exception: Exception, + expected_error: str, ) -> None: """Test we handle exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -195,7 +371,6 @@ async def test_form_exceptions( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -203,15 +378,10 @@ async def test_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": expected_error} - # On error, the form should have the previous user input, except password, - # as suggested values. + # On error, the form should have the previous user input as suggested values. data_schema = result2["data_schema"].schema - assert ( - get_schema_suggested_value(data_schema, "utility") - == "Pacific Gas and Electric Company (PG&E)" - ) assert get_schema_suggested_value(data_schema, "username") == "test-username" - assert get_schema_suggested_value(data_schema, "password") is None + assert get_schema_suggested_value(data_schema, "password") == "test-password" assert mock_login.call_count == 1 @@ -224,6 +394,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -231,7 +405,6 @@ async def test_form_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -252,6 +425,10 @@ async def test_form_not_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -259,7 +436,6 @@ async def test_form_not_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", }, @@ -299,6 +475,16 @@ async def test_form_valid_reauth( assert result["context"]["source"] == "reauth" assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -321,22 +507,23 @@ async def test_form_valid_reauth( assert mock_login.call_count == 1 -async def test_form_valid_reauth_with_mfa( +async def test_form_valid_reauth_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_unload_entry: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: - """Test that we can handle a valid reauth.""" - hass.config_entries.async_update_entry( - mock_config_entry, + """Test that we can handle a valid reauth for a utility with TOTP.""" + mock_config_entry = MockConfigEntry( + title="Consolidated Edison (ConEd) (test-username)", + domain=DOMAIN, data={ - **mock_config_entry.data, - # Requires MFA "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", }, ) + mock_config_entry.add_to_hass(hass) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) @@ -346,6 +533,17 @@ async def test_form_valid_reauth_with_mfa( assert len(flows) == 1 result = flows[0] + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + "totp_secret", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -371,3 +569,109 @@ async def test_form_valid_reauth_with_mfa( assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 + + +async def test_reauth_with_mfa_challenge( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full interactive MFA flow during reauth.""" + # 1. Set up the existing entry and trigger reauth + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # 2. Test failure before MFA challenge (InvalidAuth) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ) as mock_login_fail_auth: + result_invalid_auth = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "bad-password", + }, + ) + mock_login_fail_auth.assert_awaited_once() + assert result_invalid_auth["type"] is FlowResultType.FORM + assert result_invalid_auth["step_id"] == "reauth_confirm" + assert result_invalid_auth["errors"] == {"base": "invalid_auth"} + + # 3. Test failure before MFA challenge (CannotConnect) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=CannotConnect, + ) as mock_login_fail_connect: + result_cannot_connect = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_fail_connect.assert_awaited_once() + assert result_cannot_connect["type"] is FlowResultType.FORM + assert result_cannot_connect["step_id"] == "reauth_confirm" + assert result_cannot_connect["errors"] == {"base": "cannot_connect"} + + # 4. Trigger the MfaChallenge on the next attempt + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login_mfa: + result_mfa_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_mfa.assert_awaited_once() + + # 5. Handle the happy path for the MFA flow + assert result_mfa_challenge["type"] is FlowResultType.FORM + assert result_mfa_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + result_mfa_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Phone"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Phone") + assert result_mfa_code["type"] is FlowResultType.FORM + assert result_mfa_code["step_id"] == "mfa_code" + + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("good-code") + + # 6. Verify the reauth completes successfully + assert result_final["type"] is FlowResultType.ABORT + assert result_final["reason"] == "reauth_successful" + await hass.async_block_till_done() + + # Check that data was updated and the entry was reloaded + assert mock_config_entry.data["password"] == "new-password" + assert mock_config_entry.data["login_data"] == { + "login_data_mock_key": "login_data_mock_value" + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 1302b6744e56971828ea80b4f692fd6289a62a38 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Aug 2025 11:51:31 +0200 Subject: [PATCH 0961/1118] Deprecate MQTT vacuum battery feature and remove it as default feature (#149877) Co-authored-by: Martin Hjelmare --- homeassistant/components/mqtt/strings.json | 4 ++ homeassistant/components/mqtt/vacuum.py | 36 +++++++++-- tests/components/mqtt/test_vacuum.py | 74 ++++++++++++++++++++-- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 15285165047..77a476bf40c 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,5 +1,9 @@ { "issues": { + "deprecated_vacuum_battery_feature": { + "title": "Deprecated battery feature used", + "description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant." + }, "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index f1d2eb34fe1..28cc883fa9e 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -25,11 +25,11 @@ from homeassistant.util.json import json_loads_object from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC -from .entity import MqttEntity, async_setup_entity_entry_helper +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN +from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA -from .util import valid_publish_topic +from .util import learn_more_url, valid_publish_topic PARALLEL_UPDATES = 0 @@ -84,6 +84,8 @@ SERVICE_TO_STRING: dict[VacuumEntityFeature, str] = { VacuumEntityFeature.STOP: "stop", VacuumEntityFeature.RETURN_HOME: "return_home", VacuumEntityFeature.FAN_SPEED: "fan_speed", + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 VacuumEntityFeature.BATTERY: "battery", VacuumEntityFeature.STATUS: "status", VacuumEntityFeature.SEND_COMMAND: "send_command", @@ -96,7 +98,6 @@ DEFAULT_SERVICES = ( VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) ALL_SERVICES = ( @@ -251,10 +252,35 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) } + async def mqtt_async_added_to_hass(self) -> None: + """Check for use of deprecated battery features.""" + if self.supported_features & VacuumEntityFeature.BATTERY: + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_vacuum_battery_feature_{self.entity_id}", + issue_domain=vacuum.DOMAIN, + breaks_in_ha_version="2026.2", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(vacuum.DOMAIN), + translation_placeholders={"entity_id": self.entity_id}, + translation_key="deprecated_vacuum_battery_feature", + ) + _LOGGER.warning( + "MQTT vacuum entity %s implements the battery feature " + "which is deprecated. This will stop working " + "in Home Assistant 2026.2. Implement a separate entity " + "for the battery status instead", + self.entity_id, + ) + def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) @callback diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index ba404e2dff0..77b90403823 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -32,6 +32,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from .common import ( help_custom_config, @@ -108,7 +109,7 @@ async def test_default_supported_features( entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - ["start", "stop", "return_home", "battery", "clean_spot"] + ["start", "stop", "return_home", "clean_spot"] ) @@ -313,8 +314,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" message = """{ @@ -326,8 +325,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.DOCKED - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] @@ -337,6 +334,69 @@ async def test_status( assert state.state == STATE_UNKNOWN +# Use of the battery feature was deprecated in HA Core 2025.8 +# and will be removed with HA Core 2026.2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ({mqttvacuum.CONF_SUPPORTED_FEATURES: ["battery"]},), + ) + ], +) +async def test_status_with_deprecated_battery_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status updates from the vacuum with deprecated battery feature.""" + await mqtt_mock_entry() + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + + message = """{ + "battery_level": 54, + "state": "cleaning" + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.CLEANING + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" + + message = """{ + "battery_level": 61, + "state": "docked" + }""" + + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.DOCKED + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 + + message = '{"state":null}' + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + assert ( + "MQTT vacuum entity vacuum.mqtttest implements " + "the battery feature which is deprecated." in caplog.text + ) + + # assert a repair issue was created for the entity + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "deprecated_vacuum_battery_feature_vacuum.mqtttest" + ) + assert issue is not None + assert issue.issue_domain == "vacuum" + assert issue.translation_key == "deprecated_vacuum_battery_feature" + assert issue.translation_placeholders == {"entity_id": "vacuum.mqtttest"} + + @pytest.mark.parametrize( "hass_config", [ @@ -346,7 +406,9 @@ async def test_status( ( { mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + mqttvacuum.DEFAULT_SERVICES + | vacuum.VacuumEntityFeature.BATTERY, + SERVICE_TO_STRING, ) }, ), From 932bf81ac8dffd89011a229c3225828929e8d84e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:42:51 +0200 Subject: [PATCH 0962/1118] Add common constant `ATTR_CONFIG_ENTRY_ID` (#150067) --- homeassistant/components/amberelectric/const.py | 1 - homeassistant/components/amberelectric/services.py | 2 +- homeassistant/components/blink/const.py | 1 - homeassistant/components/blink/services.py | 4 ++-- homeassistant/components/bosch_alarm/const.py | 1 - homeassistant/components/bosch_alarm/services.py | 3 ++- homeassistant/components/ecobee/const.py | 1 - homeassistant/components/huawei_lte/__init__.py | 2 +- homeassistant/components/huawei_lte/const.py | 2 -- homeassistant/components/huawei_lte/notify.py | 4 ++-- homeassistant/components/mastodon/const.py | 1 - homeassistant/components/mastodon/services.py | 2 +- homeassistant/components/mealie/const.py | 1 - homeassistant/components/mealie/services.py | 3 +-- homeassistant/components/mobile_app/const.py | 1 - homeassistant/components/music_assistant/actions.py | 2 +- homeassistant/components/music_assistant/const.py | 1 - homeassistant/components/overseerr/const.py | 1 - homeassistant/components/overseerr/services.py | 10 ++-------- homeassistant/components/picnic/const.py | 1 - homeassistant/components/picnic/services.py | 2 +- homeassistant/components/rainbird/const.py | 1 - homeassistant/components/seventeentrack/const.py | 1 - homeassistant/components/seventeentrack/services.py | 3 +-- homeassistant/components/stookwijzer/const.py | 1 - homeassistant/components/stookwijzer/services.py | 3 ++- .../components/swiss_public_transport/const.py | 1 - .../components/swiss_public_transport/services.py | 2 +- homeassistant/components/webostv/__init__.py | 9 ++------- homeassistant/components/webostv/const.py | 1 - homeassistant/components/webostv/notify.py | 4 ++-- homeassistant/components/zwave_js/const.py | 1 - homeassistant/components/zwave_js/triggers/event.py | 8 ++++++-- .../components/zwave_js/triggers/trigger_helpers.py | 4 ++-- homeassistant/const.py | 3 +++ tests/components/amberelectric/test_services.py | 6 ++---- tests/components/blink/test_services.py | 8 ++------ tests/components/bosch_alarm/test_services.py | 2 +- tests/components/mastodon/test_services.py | 2 +- tests/components/mealie/test_services.py | 3 +-- tests/components/music_assistant/test_actions.py | 2 +- tests/components/overseerr/test_services.py | 2 +- tests/components/stookwijzer/test_services.py | 7 ++----- .../components/swiss_public_transport/test_service.py | 2 +- 44 files changed, 45 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index bdb9aa3186c..490ef3dc2dc 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -9,7 +9,6 @@ DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_CHANNEL_TYPE = "channel_type" ATTRIBUTION = "Data provided by Amber Electric" diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py index 074a2f0ac88..c22a04f2845 100644 --- a/homeassistant/components/amberelectric/services.py +++ b/homeassistant/components/amberelectric/services.py @@ -4,6 +4,7 @@ from amberelectric.models.channel import ChannelType import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,7 +17,6 @@ from homeassistant.util.json import JsonValueType from .const import ( ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, CONTROLLED_LOAD_CHANNEL, DOMAIN, FEED_IN_CHANNEL, diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 0f24eec2178..3e4ffeeea07 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -25,7 +25,6 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 1f748bd9f63..2cb6a325724 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,12 +5,12 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN +from .const import DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry SERVICE_SEND_PIN_SCHEMA = vol.Schema( diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 33ec0ae526a..d6f651e8124 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -6,4 +6,3 @@ CONF_INSTALLER_CODE = "installer_code" CONF_USER_CODE = "user_code" ATTR_DATETIME = "datetime" SERVICE_SET_DATE_TIME = "set_date_time" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index acdecbda305..f3292f97ee8 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,12 +9,13 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME from .types import BoschAlarmConfigEntry diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 115c91eceeb..cbb3a230c90 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,7 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 56b7c5023f5..a7bd90baefd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -24,6 +24,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION, @@ -54,7 +55,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ADMIN_SERVICES, ALL_KEYS, - ATTR_CONFIG_ENTRY_ID, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONF_UPNP_UDN, diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index b7662200767..bc114f56e99 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,8 +2,6 @@ DOMAIN = "huawei_lte" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" - CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 682470bafd0..7543eb71d88 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -8,12 +8,12 @@ from typing import Any from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import Router -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 2efda329467..8a77eebcf7a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,7 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" ATTR_CONTENT_WARNING = "content_warning" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 68e95e726a1..0815fee34ec 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -9,11 +9,11 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index 481cc4ccb7d..e729265bcbc 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -8,7 +8,6 @@ DOMAIN = "mealie" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" ATTR_RECIPE_ID = "recipe_id" diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f219cea1835..37b485e18f2 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -13,7 +13,7 @@ from aiomealie import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,7 +25,6 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 25c35b3e87e..1dab894b2f6 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ ATTR_APP_DATA = "app_data" ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" ATTR_APP_VERSION = "app_version" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_DEVICE_NAME = "device_name" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index 031229d1544..a0e82ba3315 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -8,6 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -24,7 +25,6 @@ from .const import ( ATTR_ALBUMS, ATTR_ARTISTS, ATTR_AUDIOBOOKS, - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, ATTR_LIBRARY_ONLY, diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index d2ee1f75028..8c1701b4afd 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -26,7 +26,6 @@ ATTR_OFFSET = "offset" ATTR_ORDER_BY = "order_by" ATTR_ALBUM_TYPE = "album_type" ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_URI = "uri" ATTR_IMAGE = "image" ATTR_VERSION = "version" diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 2aa0879ffed..da1fc051608 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,7 +9,6 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4e72f555603..3c7335de15b 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -7,6 +7,7 @@ from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,14 +18,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - ATTR_REQUESTED_BY, - ATTR_SORT_ORDER, - ATTR_STATUS, - DOMAIN, - LOGGER, -) +from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 4e8eafd8912..f8737806746 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -9,7 +9,6 @@ CONF_COORDINATOR = "coordinator" SERVICE_ADD_PRODUCT_TO_CART = "add_product" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PRODUCT_ID = "product_id" ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 8ecae8dc301..d0465fcc13c 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,12 +7,12 @@ from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, - ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 8055074f395..794afd2287b 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -8,6 +8,5 @@ CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" TIMEOUT_SECONDS = 20 diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 988a01f0022..bbf2fcf2638 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -48,4 +48,3 @@ SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_PACKAGE_FRIENDLY_NAME = "package_friendly_name" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 531ff2aea43..bd39b00071f 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -6,7 +6,7 @@ from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,7 +20,6 @@ from homeassistant.util import slugify from . import SeventeenTrackCoordinator from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, ATTR_ORIGIN_COUNTRY, diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 7b4c28540fc..65b20949fe1 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -6,5 +6,4 @@ from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py index e8c12717a21..1543d7e8777 100644 --- a/homeassistant/components/stookwijzer/services.py +++ b/homeassistant/components/stookwijzer/services.py @@ -5,6 +5,7 @@ from typing import Required, TypedDict, cast import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -13,7 +14,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ServiceValidationError -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .const import DOMAIN, SERVICE_GET_FORECAST from .coordinator import StookwijzerConfigEntry SERVICE_GET_FORECAST_SCHEMA = vol.Schema( diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index 10bfc0d0355..c6637adbbef 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -29,7 +29,6 @@ PLACEHOLDERS = { "opendata_url": "http://transport.opendata.ch", } -ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" ATTR_LIMIT: Final = "limit" SERVICE_FETCH_CONNECTIONS = "fetch_connections" diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 1ac116b4ca9..9297bd4b409 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -3,6 +3,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -19,7 +20,6 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONNECTIONS_COUNT, CONNECTIONS_MAX, diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index fb729707154..b62d7b828af 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -8,6 +8,7 @@ from aiowebostv import WebOsClient, WebOsTvPairError from homeassistant.components import notify as hass_notify from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, @@ -20,13 +21,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, - WEBOSTV_EXCEPTIONS, -) +from .const import DATA_HASS_CONFIG, DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 118ea7b32db..e8774fa24e3 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -13,7 +13,6 @@ DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_PAYLOAD = "payload" ATTR_SOUND_OUTPUT = "sound_output" diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 3966cea5e92..a2e9753c172 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -7,13 +7,13 @@ from typing import Any from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService -from homeassistant.const import ATTR_ICON +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import WebOsTvConfigEntry -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, WEBOSTV_EXCEPTIONS PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 0ccf51539d6..69987385d5a 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -92,7 +92,6 @@ ATTR_CURRENT_VALUE = "current_value" ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" ATTR_EVENT_SOURCE = "event_source" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PARTIAL_DICT_MATCH = "partial_dict_match" # service constants diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 77449af3e36..150a32113e6 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -11,7 +11,12 @@ from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_PLATFORM, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,7 +24,6 @@ from homeassistant.helpers.trigger import Trigger, TriggerActionType, TriggerInf from homeassistant.helpers.typing import ConfigType from ..const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_EVENT, ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 917d207109f..03792771bd3 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -1,12 +1,12 @@ """Helpers for Z-Wave JS custom triggers.""" from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from ..const import DOMAIN @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index 1983932813e..b678e02569c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -469,6 +469,9 @@ ATTR_NAME: Final = "name" # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID: Final = "entity_id" +# Contains one string, the config entry ID +ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" + # Contains one string or a list of strings, each being an area id ATTR_AREA_ID: Final = "area_id" diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py index 7ef895a5d88..bfff432b18c 100644 --- a/tests/components/amberelectric/test_services.py +++ b/tests/components/amberelectric/test_services.py @@ -6,10 +6,8 @@ import pytest import voluptuous as vol from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS -from homeassistant.components.amberelectric.services import ( - ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, -) +from homeassistant.components.amberelectric.services import ATTR_CHANNEL_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 856d9e6e8a0..e099b9c24e4 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,13 +4,9 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest -from homeassistant.components.blink.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_SEND_PIN, -) +from homeassistant.components.blink.const import DOMAIN, SERVICE_SEND_PIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py index 7b5088f32c3..059b01c1e3b 100644 --- a/tests/components/bosch_alarm/test_services.py +++ b/tests/components/bosch_alarm/test_services.py @@ -9,11 +9,11 @@ import pytest import voluptuous as vol from homeassistant.components.bosch_alarm.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index f51d39f8687..b08f886422f 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -6,7 +6,6 @@ from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, @@ -15,6 +14,7 @@ from homeassistant.components.mastodon.const import ( DOMAIN, ) from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 2ced94a7399..8c5d073e3e9 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -14,7 +14,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, @@ -35,7 +34,7 @@ from homeassistant.components.mealie.services import ( SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, ) -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index c13ea342262..27253ae2b20 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -11,12 +11,12 @@ from homeassistant.components.music_assistant.actions import ( SERVICE_SEARCH, ) from homeassistant.components.music_assistant.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, DOMAIN, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from .common import create_library_albums_from_fixture, setup_integration_from_fixtures diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 3d7bcc3577f..f53c6a917cb 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -7,13 +7,13 @@ from python_overseerr import OverseerrConnectionError from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py index f60730a290d..d7ec036d6e4 100644 --- a/tests/components/stookwijzer/test_services.py +++ b/tests/components/stookwijzer/test_services.py @@ -3,11 +3,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.stookwijzer.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_GET_FORECAST, -) +from homeassistant.components.stookwijzer.const import DOMAIN, SERVICE_GET_FORECAST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er diff --git a/tests/components/swiss_public_transport/test_service.py b/tests/components/swiss_public_transport/test_service.py index 135fb07fda8..b65ffc12de1 100644 --- a/tests/components/swiss_public_transport/test_service.py +++ b/tests/components/swiss_public_transport/test_service.py @@ -12,7 +12,6 @@ import pytest from voluptuous import error as vol_er from homeassistant.components.swiss_public_transport.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONF_DESTINATION, CONF_START, @@ -22,6 +21,7 @@ from homeassistant.components.swiss_public_transport.const import ( SERVICE_FETCH_CONNECTIONS, ) from homeassistant.components.swiss_public_transport.helper import unique_id_from_config +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError From 60988534a9848d44ece10d04d98fe89a7e4464e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 13:24:37 +0200 Subject: [PATCH 0963/1118] Enable disabled OpenAI config entries after entry migration (#150099) --- .../openai_conversation/__init__.py | 119 ++++- .../openai_conversation/config_flow.py | 2 +- .../openai_conversation/test_init.py | 413 +++++++++++++++++- 3 files changed, 504 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 77b71ae372d..f50563b59ea 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -272,11 +272,15 @@ async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -290,30 +294,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -333,12 +368,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, options={}, version=2, - minor_version=2, + minor_version=4, ) @@ -365,19 +401,56 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index aa1c967ca8f..c45c2b997b3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -98,7 +98,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index e728d0019b6..fb8be3b2e68 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the OpenAI integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch import httpx @@ -19,12 +20,18 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import ( DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, ) -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -585,7 +592,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 3 + assert mock_config_entry.minor_version == 4 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -714,7 +721,7 @@ async def test_migration_from_v1_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert len(entry.subentries) == 2 @@ -819,7 +826,7 @@ async def test_migration_from_v1_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert ( len(entry.subentries) == 3 @@ -855,6 +862,215 @@ async def test_migration_from_v1_with_same_keys( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="chatgpt", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "OpenAI Conversation" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -953,7 +1169,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 3 # 2 conversation + 1 AI task @@ -1089,7 +1305,7 @@ async def test_migration_from_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 2 @@ -1114,3 +1330,188 @@ async def test_migration_from_v2_2( ai_task_subentry = ai_task_subentries[0] assert ai_task_subentry.data == {"recommended": True} assert ai_task_subentry.title == "OpenAI AI Task" + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="chatgpt", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From e9444a2e4dd0dffb253c15cf7f3828eafde7fcd3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 13:24:49 +0200 Subject: [PATCH 0964/1118] Enable disabled Anthropic config entries after entry migration (#150098) --- .../components/anthropic/__init__.py | 95 +++- .../components/anthropic/config_flow.py | 2 +- tests/components/anthropic/test_init.py | 405 +++++++++++++++++- 3 files changed, 482 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e143e4d47c2..b996b7d38c5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -81,11 +81,15 @@ async def async_update_options( async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -99,30 +103,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -147,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, - minor_version=2, + minor_version=3, ) @@ -173,6 +208,38 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 099eae73d31..0c555d19bd9 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,7 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index be4f41ad4cd..ff54539bb39 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +from typing import Any from unittest.mock import patch from anthropic import ( @@ -12,9 +13,12 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -114,7 +118,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -149,6 +153,207 @@ async def test_migration_from_v1_to_v2( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="claude", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Claude conversation" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v1_to_v2_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -226,7 +431,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -320,7 +525,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -443,7 +648,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Claude" assert len(entry.subentries) == 2 @@ -500,3 +705,193 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_to_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration to version 2.3.""" + # Create a v2.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=2, + subentries_data=[ + { + "data": { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + }, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Claude haiku", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="claude", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From f26e6ad211f47e510c2f5061ce13dcf68dee5221 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 6 Aug 2025 14:14:42 +0200 Subject: [PATCH 0965/1118] Fix update coordinator ContextVar log for custom integrations (#150100) --- homeassistant/helpers/update_coordinator.py | 2 +- tests/helpers/test_update_coordinator.py | 54 +++++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6b566797017..16f3b9b6964 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -92,7 +92,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): frame.report_usage( "relies on ContextVar, but should pass the config entry explicitly.", core_behavior=frame.ReportBehavior.ERROR, - custom_integration_behavior=frame.ReportBehavior.LOG, + custom_integration_behavior=frame.ReportBehavior.IGNORE, breaks_in_ha_version="2026.8", ) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index b4216a3fc6d..57e80927e7e 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -942,17 +942,24 @@ async def test_config_entry_custom_integration( # Default without context should be None crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + # Should not log any warnings about ContextVar usage for custom integrations + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit None is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) + assert crd.config_entry is None assert ( "Detected that integration 'my_integration' relies on ContextVar" @@ -961,38 +968,53 @@ async def test_config_entry_custom_integration( # Explicit entry is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) + assert crd.config_entry is another_entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: From 25aae8944d75943241c84687d1060f4781fbd77c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:17:30 +0200 Subject: [PATCH 0966/1118] Add Tuya snapshots tests for mzj category (sous-vide) (#150102) --- tests/components/tuya/__init__.py | 6 + .../tuya/fixtures/mzj_qavcakohisj5adyh.json | 119 ++++++++++++++ .../tuya/snapshots/test_number.ambr | 116 +++++++++++++ .../tuya/snapshots/test_sensor.ambr | 153 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++ 5 files changed, 442 insertions(+) create mode 100644 tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 2c2c67aa8db..f4f99a7df6a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -274,6 +274,12 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "mzj_qavcakohisj5adyh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ], "pc_t2afic7i3v1bwhfp": [ # https://github.com/home-assistant/core/issues/149704 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json new file mode 100644 index 00000000000..402e73c732b --- /dev/null +++ b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json @@ -0,0 +1,119 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff434eca843ffc9afmthv", + "name": "Sous Vide", + "category": "mzj", + "product_id": "qavcakohisj5adyh", + "product_name": "Sous Vide", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-08T17:56:06+00:00", + "create_time": "2025-01-08T17:56:06+00:00", + "update_time": "2025-01-08T17:56:06+00:00", + "function": { + "start": { + "type": "Boolean", + "value": {} + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "start": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "cooking", "done"] + } + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "remain_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "start": false, + "status": "standby", + "cook_time": 1, + "remain_time": 1, + "cook_temperature": 550, + "temp_current": 267, + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index b05b45cdd48..7ab6af0b887 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -469,6 +469,122 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_temperature', + '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': 'Cook temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook temperature', + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_time', + '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': 'Cook time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook time', + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index e3ef0b4aa6a..1dcb262dfd5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1876,6 +1876,159 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_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.sous_vide_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sous Vide Current temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_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': None, + 'entity_id': 'sensor.sous_vide_remaining_time', + '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': 'Remaining time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvremain_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sous_vide_status', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Status', + }), + 'context': , + 'entity_id': 'sensor.sous_vide_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 4c73d91c0c9..fbbf68d2634 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1354,6 +1354,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-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': , + 'entity_id': 'switch.sous_vide_start', + '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': 'Start', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstart', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Start', + }), + 'context': , + 'entity_id': 'switch.sous_vide_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From afe574f74e6f30d79e1425d3856321aa659016d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:24:01 +0200 Subject: [PATCH 0967/1118] Simplify DPCode lookup in Tuya (#150052) --- .../components/tuya/alarm_control_panel.py | 3 ++- homeassistant/components/tuya/climate.py | 13 ++++++----- homeassistant/components/tuya/cover.py | 3 ++- homeassistant/components/tuya/entity.py | 23 ++++--------------- homeassistant/components/tuya/fan.py | 9 ++++---- homeassistant/components/tuya/humidifier.py | 6 ++--- homeassistant/components/tuya/light.py | 8 +++---- homeassistant/components/tuya/util.py | 23 +++++++++++++++++++ homeassistant/components/tuya/vacuum.py | 9 ++++---- 9 files changed, 55 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 61985fb7622..d08a3bef7ce 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -22,6 +22,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -140,7 +141,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._master_state = enum_type # Determine alarm message - if dp_code := self.find_dpcode(description.alarm_msg, prefer_function=True): + if dp_code := get_dpcode(self.device, description.alarm_msg): self._alarm_msg_dpcode = dp_code @property diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index c8071e68397..ecfc96f1d67 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -27,6 +27,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -229,7 +230,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = unknown_hvac_modes self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - elif self.find_dpcode(DPCode.SWITCH, prefer_function=True): + elif get_dpcode(self.device, DPCode.SWITCH): self._attr_hvac_modes = [ HVACMode.OFF, description.switch_only_hvac_mode, @@ -261,24 +262,24 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes - if self.find_dpcode( + if get_dpcode( + self.device, ( DPCode.SHAKE, DPCode.SWING, DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL, ), - prefer_function=True, ): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = [SWING_OFF] - if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True): + if get_dpcode(self.device, (DPCode.SHAKE, DPCode.SWING)): self._attr_swing_modes.append(SWING_ON) - if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_HORIZONTAL): self._attr_swing_modes.append(SWING_HORIZONTAL) - if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_VERTICAL): self._attr_swing_modes.append(SWING_VERTICAL) if DPCode.SWITCH in self.device.function: diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 7f34aa367ad..43e3f20deb4 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -23,6 +23,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -202,7 +203,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._attr_supported_features = CoverEntityFeature(0) # Check if this cover is based on a switch or has controls - if self.find_dpcode(description.key, prefer_function=True): + if get_dpcode(self.device, description.key): if device.function[description.key].type == "Boolean": self._attr_supported_features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index fbddfb0ab83..0ae0f793afd 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -72,22 +72,17 @@ class TuyaEntity(Entity): dptype: Literal[DPType.INTEGER], ) -> IntegerTypeData | None: ... - @overload def find_dpcode( self, dpcodes: str | DPCode | tuple[DPCode, ...] | None, *, prefer_function: bool = False, - ) -> DPCode | None: ... + dptype: DPType, + ) -> EnumTypeData | IntegerTypeData | None: + """Find type information for a matching DP code available for this device.""" + if dptype not in (DPType.ENUM, DPType.INTEGER): + raise NotImplementedError("Only ENUM and INTEGER types are supported") - def find_dpcode( - self, - dpcodes: str | DPCode | tuple[DPCode, ...] | None, - *, - prefer_function: bool = False, - dptype: DPType | None = None, - ) -> DPCode | EnumTypeData | IntegerTypeData | None: - """Find a matching DP code available on for this device.""" if dpcodes is None: return None @@ -100,11 +95,6 @@ class TuyaEntity(Entity): if prefer_function: order = ["function", "status_range"] - # When we are not looking for a specific datatype, we can append status for - # searching - if not dptype: - order.append("status") - for dpcode in dpcodes: for key in order: if dpcode not in getattr(self.device, key): @@ -133,9 +123,6 @@ class TuyaEntity(Entity): continue return integer_type - if dptype not in (DPType.ENUM, DPType.INTEGER): - return dpcode - return None def get_dptype( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 4c97b857fb7..fba42ad76cf 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -24,6 +24,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData +from .util import get_dpcode TUYA_SUPPORT_TYPE = { # Dehumidifier @@ -90,8 +91,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + self._switch = get_dpcode( + self.device, (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) ) self._attr_preset_modes = [] @@ -120,8 +121,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := self.find_dpcode( - (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL), prefer_function=True + if dpcode := get_dpcode( + self.device, (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) ): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 06fdc1545c5..5def5c5e16c 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData -from .util import ActionDPCodeNotFoundError +from .util import ActionDPCodeNotFoundError, get_dpcode @dataclass(frozen=True) @@ -105,8 +105,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): self._attr_unique_id = f"{super().unique_id}{description.key}" # Determine main switch DPCode - self._switch_dpcode = self.find_dpcode( - description.dpcode or DPCode(description.key), prefer_function=True + self._switch_dpcode = get_dpcode( + self.device, description.dpcode or DPCode(description.key) ) # Determine humidity parameters diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7b73e825900..9848351047c 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData -from .util import remap_value +from .util import get_dpcode, remap_value @dataclass @@ -515,9 +515,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): color_modes: set[ColorMode] = {ColorMode.ONOFF} # Determine DPCodes - self._color_mode_dpcode = self.find_dpcode( - description.color_mode, prefer_function=True - ) + self._color_mode_dpcode = get_dpcode(self.device, description.color_mode) if int_type := self.find_dpcode( description.brightness, dptype=DPType.INTEGER, prefer_function=True @@ -532,7 +530,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ) if ( - dpcode := self.find_dpcode(description.color_data, prefer_function=True) + dpcode := get_dpcode(self.device, description.color_data) ) and self.get_dptype(dpcode) == DPType.JSON: self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 916a7cfddf4..af6a78c1476 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -9,6 +9,29 @@ from homeassistant.exceptions import ServiceValidationError from .const import DOMAIN, DPCode +def get_dpcode( + device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None +) -> DPCode | None: + """Get the first matching DPCode from the device or return None.""" + if dpcodes is None: + return None + + if isinstance(dpcodes, DPCode): + dpcodes = (dpcodes,) + elif isinstance(dpcodes, str): + dpcodes = (DPCode(dpcodes),) + + for dpcode in dpcodes: + if ( + dpcode in device.function + or dpcode in device.status + or dpcode in device.status_range + ): + return dpcode + + return None + + def remap_value( value: float, from_min: float = 0, diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 6b4596ee053..c32d773c792 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -19,6 +19,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData +from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -88,11 +89,11 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) - if self.find_dpcode(DPCode.PAUSE, prefer_function=True): + if get_dpcode(self.device, DPCode.PAUSE): self._attr_supported_features |= VacuumEntityFeature.PAUSE self._return_home_use_switch_charge = False - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_CHARGE): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME self._return_home_use_switch_charge = True elif ( @@ -102,10 +103,10 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): ) and TUYA_MODE_RETURN_HOME in enum_type.range: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if self.find_dpcode(DPCode.SEEK, prefer_function=True): + if get_dpcode(self.device, DPCode.SEEK): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): + if get_dpcode(self.device, DPCode.POWER_GO): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START ) From a54f0adf74dd5db836ef420ac08c0e221f360f07 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 14:27:36 +0200 Subject: [PATCH 0968/1118] Enable disabled Ollama config entries after entry migration (#150105) --- homeassistant/components/ollama/__init__.py | 147 +++++-- .../components/ollama/config_flow.py | 2 +- tests/components/ollama/test_init.py | 412 +++++++++++++++++- 3 files changed, 516 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index e16550c1e94..091e58dbe7f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -92,11 +92,15 @@ async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + url_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -112,33 +116,64 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=entry.title, unique_id=None, ) - if entry.data[CONF_URL] not in api_keys_entries: + if entry.data[CONF_URL] not in url_entries: use_existing = True - api_keys_entries[entry.data[CONF_URL]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_URL] == entry.data[CONF_URL] + ) + url_entries[entry.data[CONF_URL]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_URL]] + parent_entry, all_disabled = url_entries[entry.data[CONF_URL]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -158,6 +193,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, @@ -165,7 +201,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: data={CONF_URL: entry.data[CONF_URL]}, options={}, version=3, - minor_version=1, + minor_version=3, ) @@ -211,32 +247,69 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> ) if entry.version == 3 and entry.minor_version == 1: - # Add AI Task subentry with default options. We can only create a new - # subentry if we can find an existing model in the entry. The model - # was removed in the previous migration step, so we need to - # check the subentries for an existing model. - existing_model = next( - iter( - model - for subentry in entry.subentries.values() - if (model := subentry.data.get(CONF_MODEL)) is not None - ), - None, - ) - if existing_model: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType({CONF_MODEL: existing_model}), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 3 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + # Add AI Task subentry with default options. We can only create a new + # subentry if we can find an existing model in the entry. The model + # was removed in the previous migration step, so we need to + # check the subentries for an existing model. + existing_model = next( + iter( + model + for subentry in entry.subentries.values() + if (model := subentry.data.get(CONF_MODEL)) is not None + ), + None, + ) + if existing_model: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType({CONF_MODEL: existing_model}), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index cca917f6c29..68deb00d205 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -76,7 +76,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 3 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize config flow.""" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 1db57302704..766de8a7d6d 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1,5 +1,6 @@ """Tests for the Ollama integration.""" +from typing import Any from unittest.mock import patch from httpx import ConnectError @@ -7,9 +8,12 @@ import pytest from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er, llm +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from . import TEST_OPTIONS @@ -96,7 +100,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # After migration, parent entry should only have URL assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} @@ -223,7 +227,7 @@ async def test_migration_from_v1_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 @@ -332,7 +336,7 @@ async def test_migration_from_v1_with_same_urls( entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options # Two conversation subentries from the two original entries and 1 aitask subentry assert len(entry.subentries) == 3 @@ -365,6 +369,209 @@ async def test_migration_from_v1_with_same_urls( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="ollama", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == {"model": "llama3.2:latest", **V1_TEST_OPTIONS} + assert "Ollama" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == {"model": "llama3.2:latest"} + assert ai_task_subentries[0].title == "Ollama AI Task" + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -457,7 +664,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 3 @@ -546,7 +753,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None: # Check migration to v3.1 assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # Check that model was moved from main data to subentry assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} @@ -584,6 +791,197 @@ async def test_migration_from_v3_1_without_subentry(hass: HomeAssistant) -> None await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert next(iter(mock_config_entry.subentries.values()), None) is None + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v3_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 3.2.""" + # Create a v3.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://localhost:11434"}, + disabled_by=config_entry_disabled_by, + version=3, + minor_version=2, + subentries_data=[ + { + "data": V1_TEST_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Ollama", + "unique_id": None, + }, + { + "data": {"model": "llama3.2:latest"}, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="ollama", + ) + + # Verify initial state + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 3 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From 1efe2b437dd81362525b85a22623c797de745def Mon Sep 17 00:00:00 2001 From: markhannon Date: Wed, 6 Aug 2025 22:50:06 +1000 Subject: [PATCH 0969/1118] Improve dependency transparency for Zimi integration (#145879) --- homeassistant/components/zimi/quality_scale.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 98e6c5b627c..8b8b85c71f4 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -16,6 +16,7 @@ rules: status: done comment: | https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + https://bitbucket.org/mark_hannon/zcc/src/master/bitbucket-pipelines.yml docs-actions: status: exempt comment: | From 33421bddf3771fcba2a342e3a9748f7aa800f9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 6 Aug 2025 13:51:43 +0100 Subject: [PATCH 0970/1118] Remove myself as codeowner from traccar_server (#150107) --- CODEOWNERS | 2 -- homeassistant/components/traccar_server/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5ef8479d4d3..84a07305d36 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1613,8 +1613,6 @@ build.json @home-assistant/supervisor /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus -/homeassistant/components/traccar_server/ @ludeeus -/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index 5fac2f108f7..18c30e52233 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "traccar_server", "name": "Traccar Server", - "codeowners": ["@ludeeus"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", "iot_class": "local_push", From fa3ce62ae8ca7d9b395b3a60995f5087ed506bee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 Aug 2025 14:55:00 +0200 Subject: [PATCH 0971/1118] Bump holidays to 0.78 (#150103) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 05cdd2738b6..dde50da1af3 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.77", "babel==2.15.0"] + "requirements": ["holidays==0.78", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 32edd5d3f6a..d2309702728 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.77"] + "requirements": ["holidays==0.78"] } diff --git a/requirements_all.txt b/requirements_all.txt index eb2916a4fb9..979f5c519d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend home-assistant-frontend==20250805.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cc3f6b67ab..b0d32a23186 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1020,7 +1020,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend home-assistant-frontend==20250805.0 From 2215777cfb95f00d307f989b68d19613930ab079 Mon Sep 17 00:00:00 2001 From: David Poll Date: Wed, 6 Aug 2025 06:20:03 -0700 Subject: [PATCH 0972/1118] Fix zero-argument functions with as_function (#150062) --- homeassistant/helpers/template.py | 4 ++-- tests/helpers/test_template.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 85ee1e28309..8e3106093aa 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2030,7 +2030,7 @@ def apply(value, fn, *args, **kwargs): def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" - def wrapper(value, *args, **kwargs): + def wrapper(*args, **kwargs): return_value = None def returns(value): @@ -2039,7 +2039,7 @@ def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: return value # Call the callable with the value and other args - macro(value, *args, **kwargs, returns=returns) + macro(*args, **kwargs, returns=returns) return return_value # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 82b6434cf3f..85a2673f17d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -845,6 +845,23 @@ def test_as_function(hass: HomeAssistant) -> None: ) +def test_as_function_no_arguments(hass: HomeAssistant) -> None: + """Test as_function with no arguments.""" + assert ( + template.Template( + """ + {%- macro macro_get_hello(returns) -%} + {%- do returns("Hello") -%} + {%- endmacro -%} + {%- set get_hello = macro_get_hello | as_function -%} + {{ get_hello() }} + """, + hass, + ).async_render() + == "Hello" + ) + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ From e1f6820cb6e9ade53f85649775b1eef8b9ddc399 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Aug 2025 15:22:46 +0200 Subject: [PATCH 0973/1118] Update frontend to 20250806.0 (#150106) Co-authored-by: Joost Lekkerkerker --- 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 7be7dd1def9..61ca88ba70a 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==20250805.0"] + "requirements": ["home-assistant-frontend==20250806.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b4731f66535..28e7491c48c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.2 hass-nabucasa==0.111.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 979f5c519d7..6b5d56ab6ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0d32a23186..2e0470aa7ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From 260ea9a3beb6d77315aa3c7d2a29ef8abe3d1bdd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 Aug 2025 15:24:22 +0200 Subject: [PATCH 0974/1118] Remove previously deprecated raw value attribute from onewire (#150112) --- homeassistant/components/onewire/entity.py | 2 - .../onewire/snapshots/test_binary_sensor.ambr | 16 ----- .../onewire/snapshots/test_select.ambr | 1 - .../onewire/snapshots/test_sensor.ambr | 58 ------------------- .../onewire/snapshots/test_switch.ambr | 37 ------------ 5 files changed, 114 deletions(-) diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index 64c7a8c3ebb..c66ec3bef15 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -53,8 +53,6 @@ class OneWireEntity(Entity): """Return the state attributes of the entity.""" return { "device_file": self._device_file, - # raw_value attribute is deprecated and can be removed in 2025.8 - "raw_value": self._value_raw, } def _read_value(self) -> str: diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 6309b80b28d..bce1251904a 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.A', 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_a', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.B', 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_b', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.0', 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_0', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.1', 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_1', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.2', 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_2', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.3', 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_3', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.4', 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_4', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.5', 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_5', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.6', 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_6', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.7', 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_7', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.A', 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.B', 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', @@ -640,7 +628,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.0', 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', @@ -691,7 +678,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.1', 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', @@ -742,7 +728,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.2', 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', @@ -793,7 +778,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.3', 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index 9861a7d2f5e..d699f717fea 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -52,7 +52,6 @@ '11', '12', ]), - 'raw_value': 12.0, }), 'context': , 'entity_id': 'select.28_111111111111_temperature_resolution', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 8b49b7f3d5f..f19a168456d 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -45,7 +45,6 @@ 'device_class': 'temperature', 'device_file': '/10.111111111111/temperature', 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -103,7 +102,6 @@ 'device_class': 'pressure', 'device_file': '/12.111111111111/TAI8570/pressure', 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, 'state_class': , 'unit_of_measurement': , }), @@ -161,7 +159,6 @@ 'device_class': 'temperature', 'device_file': '/12.111111111111/TAI8570/temperature', 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -215,7 +212,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.A', 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, 'state_class': , }), 'context': , @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.B', 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, 'state_class': , }), 'context': , @@ -325,7 +320,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.A', 'friendly_name': '20.111111111111 Latest voltage A', - 'raw_value': 1.11, 'state_class': , 'unit_of_measurement': , }), @@ -383,7 +377,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.B', 'friendly_name': '20.111111111111 Latest voltage B', - 'raw_value': 2.22, 'state_class': , 'unit_of_measurement': , }), @@ -441,7 +434,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.C', 'friendly_name': '20.111111111111 Latest voltage C', - 'raw_value': 3.33, 'state_class': , 'unit_of_measurement': , }), @@ -499,7 +491,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.D', 'friendly_name': '20.111111111111 Latest voltage D', - 'raw_value': 4.44, 'state_class': , 'unit_of_measurement': , }), @@ -557,7 +548,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.A', 'friendly_name': '20.111111111111 Voltage A', - 'raw_value': 1.1, 'state_class': , 'unit_of_measurement': , }), @@ -615,7 +605,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.B', 'friendly_name': '20.111111111111 Voltage B', - 'raw_value': 2.2, 'state_class': , 'unit_of_measurement': , }), @@ -673,7 +662,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.C', 'friendly_name': '20.111111111111 Voltage C', - 'raw_value': 3.3, 'state_class': , 'unit_of_measurement': , }), @@ -731,7 +719,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.D', 'friendly_name': '20.111111111111 Voltage D', - 'raw_value': 4.4, 'state_class': , 'unit_of_measurement': , }), @@ -789,7 +776,6 @@ 'device_class': 'temperature', 'device_file': '/22.111111111111/temperature', 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, 'state_class': , 'unit_of_measurement': , }), @@ -844,7 +830,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH3600/humidity', 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -899,7 +884,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH4000/humidity', 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -954,7 +938,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH5030/humidity', 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1009,7 +992,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HTM1735/humidity', 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -1064,7 +1046,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/humidity', 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1119,7 +1100,6 @@ 'device_class': 'illuminance', 'device_file': '/26.111111111111/S3-R1-A/illuminance', 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -1177,7 +1157,6 @@ 'device_class': 'pressure', 'device_file': '/26.111111111111/B1-R1-A/pressure', 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -1235,7 +1214,6 @@ 'device_class': 'temperature', 'device_file': '/26.111111111111/temperature', 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -1293,7 +1271,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VAD', 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1351,7 +1328,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VDD', 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -1409,7 +1385,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/vis', 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1467,7 +1442,6 @@ 'device_class': 'temperature', 'device_file': '/28.111111111111/temperature', 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1525,7 +1499,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222222/temperature9', 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1583,7 +1556,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222223/temperature', 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1641,7 +1613,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/temperature', 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1699,7 +1670,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/typeK/temperature', 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, 'state_class': , 'unit_of_measurement': , }), @@ -1757,7 +1727,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/vis', 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1815,7 +1784,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/volt', 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1873,7 +1841,6 @@ 'device_class': 'temperature', 'device_file': '/3B.111111111111/temperature', 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, 'state_class': , 'unit_of_measurement': , }), @@ -1931,7 +1898,6 @@ 'device_class': 'temperature', 'device_file': '/42.111111111111/temperature', 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, 'state_class': , 'unit_of_measurement': , }), @@ -1986,7 +1952,6 @@ 'device_class': 'humidity', 'device_file': '/7E.111111111111/EDS0068/humidity', 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, 'state_class': , 'unit_of_measurement': '%', }), @@ -2041,7 +2006,6 @@ 'device_class': 'illuminance', 'device_file': '/7E.111111111111/EDS0068/light', 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2099,7 +2063,6 @@ 'device_class': 'pressure', 'device_file': '/7E.111111111111/EDS0068/pressure', 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2157,7 +2120,6 @@ 'device_class': 'temperature', 'device_file': '/7E.111111111111/EDS0068/temperature', 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2215,7 +2177,6 @@ 'device_class': 'pressure', 'device_file': '/7E.222222222222/EDS0066/pressure', 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2273,7 +2234,6 @@ 'device_class': 'temperature', 'device_file': '/7E.222222222222/EDS0066/temperature', 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2328,7 +2288,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH3600/humidity', 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2383,7 +2342,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH4000/humidity', 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2438,7 +2396,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH5030/humidity', 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2493,7 +2450,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HTM1735/humidity', 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -2548,7 +2504,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/humidity', 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2603,7 +2558,6 @@ 'device_class': 'illuminance', 'device_file': '/A6.111111111111/S3-R1-A/illuminance', 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2661,7 +2615,6 @@ 'device_class': 'pressure', 'device_file': '/A6.111111111111/B1-R1-A/pressure', 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -2719,7 +2672,6 @@ 'device_class': 'temperature', 'device_file': '/A6.111111111111/temperature', 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -2777,7 +2729,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VAD', 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -2835,7 +2786,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VDD', 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -2893,7 +2843,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/vis', 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -2948,7 +2897,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_corrected', 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3003,7 +2951,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_raw', 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, 'state_class': , 'unit_of_measurement': '%', }), @@ -3061,7 +3008,6 @@ 'device_class': 'temperature', 'device_file': '/EF.111111111111/humidity/temperature', 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -3119,7 +3065,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.2', 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, 'state_class': , 'unit_of_measurement': , }), @@ -3177,7 +3122,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.3', 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, 'state_class': , 'unit_of_measurement': , }), @@ -3232,7 +3176,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.0', 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3287,7 +3230,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.1', 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index d819fdd0d54..025fbe1b64b 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/05.111111111111/PIO', 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.05_111111111111_programmed_input_output', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.A', 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_a', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.B', 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_b', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.A', 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_a', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.B', 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_b', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/26.111111111111/IAD', 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.26_111111111111_current_a_d_control', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.0', 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_0', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.1', 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_1', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.2', 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_2', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.3', 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_3', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.4', 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_4', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.5', 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_5', @@ -639,7 +627,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.6', 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_6', @@ -689,7 +676,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.7', 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_7', @@ -739,7 +725,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.0', 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_0', @@ -789,7 +774,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.1', 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_1', @@ -839,7 +823,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.2', 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_2', @@ -889,7 +872,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.3', 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_3', @@ -939,7 +921,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.4', 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_4', @@ -989,7 +970,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.5', 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_5', @@ -1039,7 +1019,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.6', 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_6', @@ -1089,7 +1068,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.7', 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_7', @@ -1139,7 +1117,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.A', 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', @@ -1189,7 +1166,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.B', 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', @@ -1239,7 +1215,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/A6.111111111111/IAD', 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.a6_111111111111_current_a_d_control', @@ -1289,7 +1264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.0', 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', @@ -1339,7 +1313,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.1', 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', @@ -1389,7 +1362,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.2', 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', @@ -1439,7 +1411,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.3', 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', @@ -1489,7 +1460,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.0', 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', @@ -1539,7 +1509,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.1', 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', @@ -1589,7 +1558,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.2', 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', @@ -1639,7 +1607,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.3', 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', @@ -1689,7 +1656,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.0', 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_0', @@ -1739,7 +1705,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.1', 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_1', @@ -1789,7 +1754,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.2', 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_2', @@ -1839,7 +1803,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.3', 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_3', From 124e7cf4c8fa2b1e368f0b7ec849cd7f4f7a40a2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:38:50 +0200 Subject: [PATCH 0975/1118] Add support for tuya ywcgq category (liquid level) (#150096) Thanks @joostlek / @frenck --- homeassistant/components/tuya/const.py | 7 + homeassistant/components/tuya/number.py | 26 + homeassistant/components/tuya/sensor.py | 19 + homeassistant/components/tuya/strings.json | 26 + tests/components/tuya/__init__.py | 10 +- .../tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json | 148 ++++++ .../tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json | 33 +- .../tuya/snapshots/test_number.ambr | 468 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 312 ++++++++++++ 9 files changed, 1031 insertions(+), 18 deletions(-) create mode 100644 tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e5a37d272ef..38661d548a7 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -222,6 +222,7 @@ class DPCode(StrEnum): HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity + INSTALLATION_HEIGHT = "installation_height" IPC_WORK_MODE = "ipc_work_mode" LED_TYPE_1 = "led_type_1" LED_TYPE_2 = "led_type_2" @@ -232,12 +233,18 @@ class DPCode(StrEnum): LEVEL_CURRENT = "level_current" LIGHT = "light" # Light LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" LOCK = "lock" # Lock / Child lock MASTER_MODE = "master_mode" # alarm mode MASTER_STATE = "master_state" # alarm state MACH_OPERATE = "mach_operate" MANUAL_FEED = "manual_feed" MATERIAL = "material" # Material + MAX_SET = "max_set" + MINI_SET = "mini_set" MODE = "mode" # Working mode / Mode MOODLIGHTING = "moodlighting" # Mood light MOTION_RECORD = "motion_record" diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index e7988adfafb..88216ae3d06 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -339,6 +339,32 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + NumberEntityDescription( + key=DPCode.MAX_SET, + translation_key="alarm_maximum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.MINI_SET, + translation_key="alarm_minimum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.INSTALLATION_HEIGHT, + translation_key="installation_height", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.LIQUID_DEPTH_MAX, + translation_key="maximum_liquid_depth", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 5ca6e1d77a0..9eb05186f63 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1301,6 +1301,25 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + TuyaSensorEntityDescription( + key=DPCode.LIQUID_STATE, + translation_key="liquid_state", + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_DEPTH, + translation_key="depth", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_LEVEL_PERCENT, + translation_key="liquid_level", + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ee9548cdef9..d660c9c910d 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -213,6 +213,18 @@ }, "siren_duration": { "name": "Siren duration" + }, + "alarm_maximum": { + "name": "Alarm maximum" + }, + "alarm_minimum": { + "name": "Alarm minimum" + }, + "installation_height": { + "name": "Installation height" + }, + "maximum_liquid_depth": { + "name": "Maximum liquid depth" } }, "select": { @@ -711,6 +723,20 @@ "charging": "[%key:common::state::charging%]", "charge_done": "Charge done" } + }, + "liquid_state": { + "name": "Liquid state", + "state": { + "normal": "[%key:common::state::normal%]", + "lower_alarm": "[%key:common::state::low%]", + "upper_alarm": "[%key:common::state::high%]" + } + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level": { + "name": "Liquid level" } }, "switch": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index f4f99a7df6a..e68c2a73d55 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -434,9 +434,15 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "ywcgq_h8lvyoahr6s6aybf": [ + # https://github.com/home-assistant/core/issues/145932 + Platform.NUMBER, + Platform.SENSOR, + ], "ywcgq_wtzwyhkev3b4ubns": [ - # https://community.home-assistant.io/t/something-is-wrong-with-tuya-tank-level-sensors-with-the-new-official-integration/689321 - # not (yet) supported + # https://github.com/home-assistant/core/issues/103818 + Platform.NUMBER, + Platform.SENSOR, ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 diff --git a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json new file mode 100644 index 00000000000..8b1cff0c773 --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json @@ -0,0 +1,148 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3d16d38b17d7034ddxd4", + "name": "Rainwater Tank Level", + "category": "ywcgq", + "product_id": "h8lvyoahr6s6aybf", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-31T09:55:19+00:00", + "create_time": "2025-05-31T09:55:19+00:00", + "update_time": "2025-05-31T09:55:19+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 3, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "normal", + "liquid_depth": 455, + "max_set": 90, + "mini_set": 10, + "upper_switch": false, + "installation_height": 1350, + "liquid_depth_max": 100, + "liquid_level_percent": 36 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json index f724ffe164f..52eda664345 100644 --- a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -1,20 +1,22 @@ { - "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "tuyaSmart", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf27a4********368f4w", - "name": "Nivel del tanque A", + "name": "House Water Level", + "model": "EPT_Ultrasonic level sensor", "category": "ywcgq", "product_id": "wtzwyhkev3b4ubns", "product_name": "Tank A Level", "online": true, "sub": false, - "time_zone": "+01:00", - "active_time": "2024-01-05T10:22:24+00:00", - "create_time": "2024-01-05T10:22:24+00:00", - "update_time": "2024-01-05T10:22:24+00:00", + "time_zone": "-06:00", + "active_time": "2023-11-02T22:48:03+00:00", + "create_time": "2023-11-02T22:48:03+00:00", + "update_time": "2023-11-09T13:32:38+00:00", "function": { "max_set": { "type": "Integer", @@ -126,14 +128,13 @@ } }, "status": { - "liquid_state": "normal", - "liquid_depth": 77, + "liquid_state": "upper_alarm", + "liquid_depth": 42, "max_set": 100, - "mini_set": 10, - "installation_height": 980, - "liquid_depth_max": 140, - "liquid_level_percent": 97 + "mini_set": 0, + "installation_height": 560, + "liquid_depth_max": 100, + "liquid_level_percent": 100 }, - "set_up": false, - "support_local": true + "terminal_id": "REDACTED" } diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 7ab6af0b887..b5d6224ecea 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -817,3 +817,471 @@ 'state': '-1.5', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4max_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4mini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4installation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Installation height', + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.35', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.mocked_device_idmax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.mocked_device_idmini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.mocked_device_idinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Installation height', + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.56', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Maximum liquid depth', + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 1dcb262dfd5..fcd14667d36 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4421,6 +4421,318 @@ 'state': 'low', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-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.rainwater_tank_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.455', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-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.rainwater_tank_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-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.house_water_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-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.house_water_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.mocked_device_idliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.mocked_device_idliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'upper_alarm', + }) +# --- # name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 76ca9ce3a4a563aea1cb845e886b6793589e6b07 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:12:32 +0200 Subject: [PATCH 0976/1118] Add comment to Tuya code for unsupported devices (#150125) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index e8aa6bded22..6ed8f0253ab 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -165,6 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool identifiers={(DOMAIN, device.id)}, manufacturer="Tuya", name=device.name, + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported model=f"{device.product_name} (unsupported)", model_id=device.product_id, ) From d0cc9990dd91c520e9851ab155d2d6ed6f7cbfc2 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Wed, 6 Aug 2025 17:32:23 +0200 Subject: [PATCH 0977/1118] Deprecate Roborock battery feature (#150126) --- homeassistant/components/roborock/vacuum.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 058fffbdb1c..4bf3c49a726 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -109,7 +109,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.CLEAN_SPOT @@ -142,11 +141,6 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): assert self._device_status.state is not None return STATE_CODE_TO_STATE.get(self._device_status.state) - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - return self._device_status.battery - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" From 4e2fe631827f6240871b8a9097be550d3b0e7458 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Wed, 6 Aug 2025 18:08:51 +0200 Subject: [PATCH 0978/1118] Check for Z-Wave firmware updates of sleeping devices (#150123) --- homeassistant/components/zwave_js/update.py | 20 -------- tests/components/zwave_js/test_update.py | 57 +++------------------ 2 files changed, 8 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 42a4b4cf6dd..88e1a22c00f 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta from typing import Any, Final, cast from awesomeversion import AwesomeVersion -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver from zwave_js_server.model.firmware import ( @@ -192,7 +191,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): self.entity_description = entity_description self.node = node self._latest_version_firmware: FirmwareUpdateInfo | None = None - self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None self._finished_unsub: Callable[[], None] | None = None @@ -213,12 +211,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): """Return ZWave Node Firmware Update specific state data to be restored.""" return ZWaveFirmwareUpdateExtraStoredData(self._latest_version_firmware) - @callback - def _update_on_status_change(self, _: dict[str, Any]) -> None: - """Update the entity when node is awake.""" - self._status_unsub = None - self.hass.async_create_task(self._async_update()) - @callback def update_progress(self, event: dict[str, Any]) -> None: """Update install progress on event.""" @@ -270,14 +262,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): ) return - # If device is asleep, wait for it to wake up before attempting an update - if self.node.status == NodeStatus.ASLEEP: - if not self._status_unsub: - self._status_unsub = self.node.once( - "wake up", self._update_on_status_change - ) - return - try: # Retrieve all firmware updates including non-stable ones but filter # non-stable channels out @@ -436,10 +420,6 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" - if self._status_unsub: - self._status_unsub() - self._status_unsub = None - if self._poll_unsub: self._poll_unsub() self._poll_unsub = None diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fbe0a8bbea7..d7243268b9e 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -269,7 +269,7 @@ async def test_update_entity_sleep( zen_31: Node, integration: MockConfigEntry, ) -> None: - """Test update occurs when device is asleep after it wakes up.""" + """Test update occurs when device is asleep.""" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": zen_31.node_id}, @@ -283,29 +283,13 @@ async def test_update_entity_sleep( await hass.async_block_till_done() # Two nodes in total, the controller node and the zen_31 node. - # The zen_31 node is asleep, - # so we should only check for updates for the controller node. - assert client.async_send_command.call_count == 1 - args = client.async_send_command.call_args[0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == 1 - - client.async_send_command.reset_mock() - - event = Event( - "wake up", - data={"source": "node", "event": "wake up", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the zen_31 node is awake we can check for updates for it. - # The controller node has already been checked, - # so won't get another check now. - assert client.async_send_command.call_count == 1 - args = client.async_send_command.call_args[0][0] - assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == 94 + # We should check for updates for both nodes, including the sleeping one + # since the firmware check no longer requires device communication first. + assert client.async_send_command.call_count == 2 + # Check calls were made for both nodes + call_args = [call[0][0] for call in client.async_send_command.call_args_list] + assert any(args["nodeId"] == 1 for args in call_args) # Controller node + assert any(args["nodeId"] == 94 for args in call_args) # zen_31 node async def test_update_entity_dead( @@ -1158,28 +1142,3 @@ async def test_update_entity_no_latest_version( assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == latest_version - - -async def test_update_entity_unload_asleep_node( - hass: HomeAssistant, - client: MagicMock, - wallmote_central_scene: Node, - integration: MockConfigEntry, -) -> None: - """Test unloading config entry after attempting an update for an asleep node.""" - config_entry = integration - assert client.async_send_command.call_count == 0 - - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"updates": []} - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) - await hass.async_block_till_done() - - # Once call completed for the (awake) controller node. - assert client.async_send_command.call_count == 1 - assert len(wallmote_central_scene._listeners["wake up"]) == 1 - - await hass.config_entries.async_unload(config_entry.entry_id) - assert client.async_send_command.call_count == 1 - assert len(wallmote_central_scene._listeners["wake up"]) == 0 From 06130219b4c50aec24e697a073279cd8a860229b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:20:30 +0200 Subject: [PATCH 0979/1118] Use relative condition keys (#150021) --- .../components/device_automation/condition.py | 2 +- homeassistant/components/sun/condition.py | 2 +- homeassistant/components/zone/condition.py | 2 +- homeassistant/helpers/condition.py | 72 +++++++++++-------- script/hassfest/conditions.py | 2 +- script/hassfest/icons.py | 2 +- script/hassfest/translations.py | 2 +- .../components/websocket_api/test_commands.py | 9 +-- tests/helpers/test_condition.py | 24 +++---- 9 files changed, 65 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 426cc45a895..63be9641aeb 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -80,7 +80,7 @@ class DeviceCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "device": DeviceCondition, + "_device": DeviceCondition, } diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 15f3ea90c73..415d0a04e7c 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -153,7 +153,7 @@ class SunCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "sun": SunCondition, + "_": SunCondition, } diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index b0fe30b26fd..cc2429ed3a4 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -147,7 +147,7 @@ class ZoneCondition(Condition): CONDITIONS: dict[str, type[Condition]] = { - "zone": ZoneCondition, + "_": ZoneCondition, } diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5aa39e73166..d9f16217c2e 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -58,9 +58,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict -from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, entity_registry as er +from .automation import get_absolute_description_key, get_relative_description_key from .integration_platform import async_process_integration_platforms from .template import Template, render_complex from .trace import ( @@ -132,7 +132,7 @@ def starts_with_dot(key: str) -> str: _CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, starts_with_dot)): object, - cv.slug: vol.Any(None, _CONDITION_SCHEMA), + cv.underscore_slug: vol.Any(None, _CONDITION_SCHEMA), } ) @@ -171,6 +171,9 @@ async def _register_condition_platform( if hasattr(platform, "async_get_conditions"): for condition_key in await platform.async_get_conditions(hass): + condition_key = get_absolute_description_key( + integration_domain, condition_key + ) hass.data[CONDITIONS][condition_key] = integration_domain new_conditions.add(condition_key) else: @@ -288,22 +291,21 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def _async_get_condition_platform( - hass: HomeAssistant, config: ConfigType -) -> ConditionProtocol | None: - condition_key: str = config[CONF_CONDITION] - platform_and_sub_type = condition_key.partition(".") + hass: HomeAssistant, condition_key: str +) -> tuple[str, ConditionProtocol | None]: + platform_and_sub_type = condition_key.split(".") platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: - return None + return "", None try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{condition_key}" specified {config}' + f'Invalid condition "{condition_key}" specified' ) from None try: - return await integration.async_get_platform("condition") + return platform, await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" @@ -339,17 +341,20 @@ async def async_from_config( return disabled_condition - condition: str = config[CONF_CONDITION] + condition_key: str = config[CONF_CONDITION] factory: Any = None - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - condition_instance = condition_descriptors[condition](hass, config) + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + condition_instance = condition_descriptors[relative_condition_key](hass, config) return await condition_instance.async_get_checker() for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory = getattr(sys.modules[__name__], fmt.format(condition_key), None) if factory: break @@ -960,8 +965,9 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition: str = config[CONF_CONDITION] - if condition in ("and", "not", "or"): + condition_key: str = config[CONF_CONDITION] + + if condition_key in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: sub_cond = await async_validate_condition_config(hass, sub_cond) @@ -969,16 +975,23 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - platform = await _async_get_condition_platform(hass, config) + platform_domain, platform = await _async_get_condition_platform(hass, condition_key) + if platform is not None: condition_descriptors = await platform.async_get_conditions(hass) - if not (condition_class := condition_descriptors.get(condition)): - raise vol.Invalid(f"Invalid condition '{condition}' specified") + relative_condition_key = get_relative_description_key( + platform_domain, condition_key + ) + if not (condition_class := condition_descriptors.get(relative_condition_key)): + raise vol.Invalid(f"Invalid condition '{condition_key}' specified") return await condition_class.async_validate_config(hass, config) - if platform is None and condition in ("numeric_state", "state"): + + if platform is None and condition_key in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], - getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), + getattr( + sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition_key) + ), ) return validator(hass, config) @@ -1088,11 +1101,11 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]: return referenced -def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: +def _load_conditions_file(integration: Integration) -> dict[str, Any]: """Load conditions file for an integration.""" try: return cast( - JSON_TYPE, + dict[str, Any], _CONDITIONS_SCHEMA( load_yaml_dict(str(integration.file_path / "conditions.yaml")) ), @@ -1112,11 +1125,14 @@ def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON def _load_conditions_files( - hass: HomeAssistant, integrations: Iterable[Integration] -) -> dict[str, JSON_TYPE]: + integrations: Iterable[Integration], +) -> dict[str, dict[str, Any]]: """Load condition files for multiple integrations.""" return { - integration.domain: _load_conditions_file(hass, integration) + integration.domain: { + get_absolute_description_key(integration.domain, key): value + for key, value in _load_conditions_file(integration).items() + } for integration in integrations } @@ -1137,7 +1153,7 @@ async def async_get_all_descriptions( return descriptions_cache # Files we loaded for missing descriptions - new_conditions_descriptions: dict[str, JSON_TYPE] = {} + new_conditions_descriptions: dict[str, dict[str, Any]] = {} # We try to avoid making a copy in the event the cache is good, # but now we must make a copy in case new conditions get added # while we are loading the missing ones so we do not @@ -1166,7 +1182,7 @@ async def async_get_all_descriptions( if integrations: new_conditions_descriptions = await hass.async_add_executor_job( - _load_conditions_files, hass, integrations + _load_conditions_files, integrations ) # Make a copy of the old cache and add missing descriptions to it @@ -1175,7 +1191,7 @@ async def async_get_all_descriptions( domain = conditions[missing_condition] if ( - yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr] + yaml_description := new_conditions_descriptions.get(domain, {}).get( missing_condition ) ) is None: diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 2a1d363a5fc..b9e9e7b82a4 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -47,7 +47,7 @@ CONDITION_SCHEMA = vol.Any( CONDITIONS_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, condition.starts_with_dot)): object, - cv.slug: CONDITION_SCHEMA, + cv.underscore_slug: CONDITION_SCHEMA, } ) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index ba6ac5e88c8..6d2187e3fe6 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -126,7 +126,7 @@ CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Optional("condition"): icon_value_validator, } ), - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 76af88f8dec..d09fb27f71a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -434,7 +434,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: slug_validator=translation_key_validator, ), }, - slug_validator=translation_key_validator, + slug_validator=cv.underscore_slug, ), vol.Optional("triggers"): cv.schema_with_slug_keys( { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 263cd4a4ed8..846b3657bb2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -721,10 +721,10 @@ async def test_subscribe_conditions( ) -> None: """Test condition_platforms/subscribe command.""" sun_condition_descriptions = """ - sun: {} + _: {} """ device_automation_condition_descriptions = """ - device: {} + _device: {} """ def _load_yaml(fname, secrets=None): @@ -2738,10 +2738,7 @@ async def test_validate_config_works( "entity_id": "hello.world", "state": "paulus", }, - ( - "Invalid condition \"non_existing\" specified {'condition': " - "'non_existing', 'entity_id': 'hello.world', 'state': 'paulus'}" - ), + 'Invalid condition "non_existing" specified', ), # Raises HomeAssistantError ( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 94e71696270..b037d6a450e 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2073,7 +2073,7 @@ async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( "homeassistant.components.device_automation.condition.async_get_conditions", - AsyncMock(return_value={"device": AsyncMock()}), + AsyncMock(return_value={"_device": AsyncMock()}), ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) device_automation_async_get_conditions_mock.assert_awaited() @@ -2113,8 +2113,8 @@ async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: hass: HomeAssistant, ) -> dict[str, type[condition.Condition]]: return { - "test": MockCondition1, - "test.cond_2": MockCondition2, + "_": MockCondition1, + "cond_2": MockCondition2, } mock_integration(hass, MockModule("test")) @@ -2337,7 +2337,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None "sun_condition_descriptions", [ """ - sun: + _: fields: after: example: sunrise @@ -2371,7 +2371,7 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None .offset_selector: &offset_selector selector: time: null - sun: + _: fields: after: *sunrise_sunset_selector after_offset: *offset_selector @@ -2385,7 +2385,7 @@ async def test_async_get_all_descriptions( ) -> None: """Test async_get_all_descriptions.""" device_automation_condition_descriptions = """ - device: {} + _device: {} """ assert await async_setup_component(hass, DOMAIN_SUN, {}) @@ -2415,7 +2415,7 @@ async def test_async_get_all_descriptions( # Test we only load conditions.yaml for integrations with conditions, # system_health has no conditions - assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered( + assert proxy_load_conditions_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, DOMAIN_SUN), ] @@ -2423,7 +2423,7 @@ async def test_async_get_all_descriptions( # system_health does not have conditions and should not be in descriptions assert descriptions == { - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2459,7 +2459,7 @@ async def test_async_get_all_descriptions( "device": { "fields": {}, }, - DOMAIN_SUN: { + "sun": { "fields": { "after": { "example": "sunrise", @@ -2525,7 +2525,7 @@ async def test_async_get_all_descriptions_with_bad_description( ) -> None: """Test async_get_all_descriptions.""" sun_service_descriptions = """ - sun: + _: fields: not_a_dict """ @@ -2545,11 +2545,11 @@ async def test_async_get_all_descriptions_with_bad_description( ): descriptions = await condition.async_get_all_descriptions(hass) - assert descriptions == {DOMAIN_SUN: None} + assert descriptions == {"sun": None} assert ( "Unable to parse conditions.yaml for the sun integration: " - "expected a dictionary for dictionary value @ data['sun']['fields']" + "expected a dictionary for dictionary value @ data['_']['fields']" ) in caplog.text From 757fee9f7331d59b757c2d3a48171ea61a9c6691 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 6 Aug 2025 18:48:55 +0200 Subject: [PATCH 0980/1118] Use state selector for climate set hvac mode service (#148963) --- homeassistant/components/climate/services.yaml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index fb5ba4f1796..8ef1b984ff9 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -100,16 +100,10 @@ set_hvac_mode: fields: hvac_mode: selector: - select: - options: - - "off" - - "auto" - - "cool" - - "dry" - - "fan_only" - - "heat_cool" - - "heat" - translation_key: hvac_mode + state: + hide_states: + - unavailable + - unknown set_swing_mode: target: entity: From 2b5028bfb7c7a2522ca6a66fdcf8ab76d621bcd2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:56:44 -0400 Subject: [PATCH 0981/1118] Bump ZHA to 0.0.67 (#150132) --- 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 38ce08aa782..9842fa7a0f3 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.66"], + "requirements": ["zha==0.0.67"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 6b5d56ab6ba..efeac801e40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3203,7 +3203,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.66 +zha==0.0.67 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e0470aa7ad..d3c197fa745 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2647,7 +2647,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.66 +zha==0.0.67 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From e5d512d5e503dc32c1d9f2f580a8f47a8fd08fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 6 Aug 2025 19:03:09 +0100 Subject: [PATCH 0982/1118] Add entity filter to target state change tracker (#150064) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/target.py | 9 ++- tests/helpers/test_target.py | 127 ++++++++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 0b902ea4d23..5286daaeef0 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -268,11 +268,13 @@ class TargetStateChangeTracker: hass: HomeAssistant, selector_data: TargetSelectorData, action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]], ) -> None: """Initialize the state change tracker.""" self._hass = hass self._selector_data = selector_data self._action = action + self._entity_filter = entity_filter self._state_change_unsub: CALLBACK_TYPE | None = None self._registry_unsubs: list[CALLBACK_TYPE] = [] @@ -289,7 +291,9 @@ class TargetStateChangeTracker: self._hass, self._selector_data, expand_group=False ) - tracked_entities = selected.referenced.union(selected.indirectly_referenced) + tracked_entities = self._entity_filter( + selected.referenced.union(selected.indirectly_referenced) + ) @callback def state_change_listener(event: Event[EventStateChangedData]) -> None: @@ -348,6 +352,7 @@ def async_track_target_selector_state_change_event( hass: HomeAssistant, target_selector_config: ConfigType, action: Callable[[TargetStateChangedData], Any], + entity_filter: Callable[[set[str]], set[str]] = lambda x: x, ) -> CALLBACK_TYPE: """Track state changes for entities referenced directly or indirectly in a target selector.""" selector_data = TargetSelectorData(target_selector_config) @@ -355,5 +360,5 @@ def async_track_target_selector_state_change_event( raise HomeAssistantError( f"Target selector {target_selector_config} does not have any selectors defined" ) - tracker = TargetStateChangeTracker(hass, selector_data, action) + tracker = TargetStateChangeTracker(hass, selector_data, action, entity_filter) return tracker.async_setup() diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index fa31ef375fd..09fb16cbe9a 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -36,6 +36,29 @@ from tests.common import ( ) +async def set_states_and_check_target_events( + hass: HomeAssistant, + events: list[target.TargetStateChangedData], + state: str, + entities_to_set_state: list[str], + entities_to_assert_change: list[str], +) -> None: + """Toggle the state entities and check for events.""" + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + assert len(events) == len(entities_to_assert_change) + entities_seen = set() + for event in events: + state_change_event = event.state_change_event + entities_seen.add(state_change_event.data["entity_id"]) + assert state_change_event.data["new_state"].state == state + assert event.targeted_entity_ids == set(entities_to_assert_change) + assert entities_seen == set(entities_to_assert_change) + events.clear() + + @pytest.fixture def registries_mock(hass: HomeAssistant) -> None: """Mock including floor and area info.""" @@ -497,19 +520,9 @@ async def test_async_track_target_selector_state_change_event( """Toggle the state entities and check for events.""" nonlocal last_state last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF - for entity_id in entities_to_set_state: - hass.states.async_set(entity_id, last_state) - await hass.async_block_till_done() - - assert len(events) == len(entities_to_assert_change) - entities_seen = set() - for event in events: - state_change_event = event.state_change_event - entities_seen.add(state_change_event.data["entity_id"]) - assert state_change_event.data["new_state"].state == last_state - assert event.targeted_entity_ids == set(entities_to_assert_change) - assert entities_seen == set(entities_to_assert_change) - events.clear() + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) @@ -645,3 +658,91 @@ async def test_async_track_target_selector_state_change_event( # After unsubscribing, changes should not trigger unsub() await set_states_and_check_events(targeted_entities, []) + + +async def test_async_track_target_selector_state_change_event_filter( + hass: HomeAssistant, +) -> None: + """Test async_track_target_selector_state_change_event with entity filter.""" + events: list[target.TargetStateChangedData] = [] + + filtered_entity = "" + + @callback + def entity_filter(entity_ids: set[str]) -> set[str]: + return {entity_id for entity_id in entity_ids if entity_id != filtered_entity} + + @callback + def state_change_callback(event: target.TargetStateChangedData): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + await set_states_and_check_target_events( + hass, events, last_state, entities_to_set_state, entities_to_assert_change + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + entity_reg = er.async_get(hass) + + label = lr.async_get(hass).async_create("Test Label").name + label_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="label_light", + ).entity_id + entity_reg.async_update_entity(label_entity, labels={label}) + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, label_entity] + await set_states_and_check_events(targeted_entities, []) + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback, entity_filter + ) + + await set_states_and_check_events( + targeted_entities, [targeted_entity, label_entity] + ) + + filtered_entity = targeted_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events([targeted_entity, label_entity], [label_entity]) + + filtered_entity = label_entity + # Fire an event so that the targeted entities are re-evaluated + hass.bus.async_fire( + er.EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": "light.other", + "changes": {}, + }, + ) + await set_states_and_check_events( + [targeted_entity, label_entity], [targeted_entity] + ) + + unsub() From 35025c4b598dea294f0db254e8c872f082447f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 7 Aug 2025 00:05:31 +0100 Subject: [PATCH 0983/1118] Fix roborock config flow tests (#150135) --- tests/components/roborock/test_config_flow.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 7958f17a696..994f58513d2 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -239,8 +239,11 @@ async def test_reauth_flow( assert result["step_id"] == "reauth_confirm" # Request a new code - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -250,9 +253,12 @@ async def test_reauth_flow( assert result["type"] is FlowResultType.FORM new_user_data = deepcopy(USER_DATA) new_user_data.rriot.s = "new_password_hash" - with patch( - "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", - return_value=new_user_data, + with ( + patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=new_user_data, + ), + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} From d17f0ef55a6930a6919fe4684fd89936c8eff785 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Aug 2025 20:07:31 -1000 Subject: [PATCH 0984/1118] Bump inkbird-ble to 1.1.0 to add support for IAM-T2 (#150158) --- homeassistant/components/inkbird/manifest.json | 11 ++++++++++- homeassistant/generated/bluetooth.py | 15 +++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 9c73c4d970f..721c462c800 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -42,10 +42,19 @@ "local_name": "Ink@IAM-T1", "connectable": true }, + { + "local_name": "Ink@IAM-T2", + "connectable": true + }, { "manufacturer_id": 12628, "manufacturer_data_start": [65, 67, 45], "connectable": true + }, + { + "manufacturer_id": 12884, + "manufacturer_data_start": [0, 98, 0], + "connectable": false } ], "codeowners": ["@bdraco"], @@ -53,5 +62,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.16.2"] + "requirements": ["inkbird-ble==1.1.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index f5303f09302..da6cab4bc22 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -386,6 +386,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "Ink@IAM-T1", }, + { + "connectable": True, + "domain": "inkbird", + "local_name": "Ink@IAM-T2", + }, { "connectable": True, "domain": "inkbird", @@ -396,6 +401,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 12628, }, + { + "connectable": False, + "domain": "inkbird", + "manufacturer_data_start": [ + 0, + 98, + 0, + ], + "manufacturer_id": 12884, + }, { "connectable": True, "domain": "iron_os", diff --git a/requirements_all.txt b/requirements_all.txt index efeac801e40..33adf61382a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1252,7 +1252,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3c197fa745..7d27477cabe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1086,7 +1086,7 @@ influxdb-client==1.48.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.16.2 +inkbird-ble==1.1.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 566aeb5e9aac8d992c6ed831853454830b9d8b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 7 Aug 2025 08:08:47 +0200 Subject: [PATCH 0985/1118] Bump letpot to 0.6.1 (#150137) --- homeassistant/components/letpot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/__init__.py | 5 +++-- tests/components/letpot/conftest.py | 9 +++++++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 6ee6a309cac..1397775b351 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.5.0"] + "requirements": ["letpot==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 33adf61382a..436b8e1245b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1340,7 +1340,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.5.0 +letpot==0.6.1 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d27477cabe..6f2fc9b1e72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1159,7 +1159,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.5.0 +letpot==0.6.1 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 6e73bb430cf..d8be422899a 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -6,6 +6,7 @@ from letpot.models import ( AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus, + LightMode, TemperatureUnit, ) @@ -33,7 +34,7 @@ AUTHENTICATION = AuthenticationInfo( MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, - light_mode=1, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, @@ -53,7 +54,7 @@ MAX_STATUS = LetPotDeviceStatus( SE_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), light_brightness=500, - light_mode=1, + light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), online=True, diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 6d59f8bd2ef..03ce2ec4a0d 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -44,10 +44,15 @@ def _mock_device_info(device_type: str) -> LetPotDeviceInfo: def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": - return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + return ( + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH + | DeviceFeature.PUMP_STATUS + ) if device_type == "LPH63": return ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LEVELS | DeviceFeature.NUTRIENT_BUTTON | DeviceFeature.PUMP_AUTO | DeviceFeature.PUMP_STATUS From da7fc88f1f5989ce7a6594b474e9b424d7e5c6e0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 7 Aug 2025 08:13:11 +0200 Subject: [PATCH 0986/1118] Bump pymodbus to v3.11.0. (#150129) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 555026b4bda..656b69920a0 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.9.2"] + "requirements": ["pymodbus==3.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 436b8e1245b..3edf7b654ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2155,7 +2155,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.0 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f2fc9b1e72..a75ee0920f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1794,7 +1794,7 @@ pymiele==0.5.2 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.9.2 +pymodbus==3.11.0 # homeassistant.components.monoprice pymonoprice==0.4 From efebdc018103fd1623ac6760aba02b79960692b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:42:36 +0200 Subject: [PATCH 0987/1118] Add Tuya snapshots tests for cl category (curtains) (#150167) --- tests/components/tuya/__init__.py | 19 ++ .../tuya/fixtures/cl_3r8gc33pnqsxfe1g.json | 123 +++++++++++ .../components/tuya/fixtures/cl_cpbo62rn.json | 102 +++++++++ .../tuya/fixtures/cl_ebt12ypvexnixvtf.json | 58 +++++ .../components/tuya/fixtures/cl_qqdxfdht.json | 67 ++++++ .../components/tuya/snapshots/test_cover.ambr | 204 ++++++++++++++++++ .../tuya/snapshots/test_select.ambr | 57 +++++ .../tuya/snapshots/test_sensor.ambr | 49 +++++ .../tuya/snapshots/test_switch.ambr | 48 +++++ 9 files changed, 727 insertions(+) create mode 100644 tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json create mode 100644 tests/components/tuya/fixtures/cl_cpbo62rn.json create mode 100644 tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json create mode 100644 tests/components/tuya/fixtures/cl_qqdxfdht.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index e68c2a73d55..6fd429d6391 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -14,6 +14,25 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "cl_3r8gc33pnqsxfe1g": [ + # https://github.com/tuya/tuya-home-assistant/issues/754 + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, + ], + "cl_cpbo62rn": [ + # https://github.com/orgs/home-assistant/discussions/539 + Platform.COVER, + Platform.SELECT, + ], + "cl_ebt12ypvexnixvtf": [ + # https://github.com/tuya/tuya-home-assistant/issues/754 + Platform.COVER, + ], + "cl_qqdxfdht": [ + # https://github.com/orgs/home-assistant/discussions/539 + Platform.COVER, + ], "cl_zah67ekd": [ # https://github.com/home-assistant/core/issues/71242 Platform.COVER, diff --git a/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json new file mode 100644 index 00000000000..de6c23a1c14 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json @@ -0,0 +1,123 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lounge Dark Blind", + "model": null, + "category": "cl", + "product_id": "3r8gc33pnqsxfe1g", + "product_name": "Blinds Controller", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-01T20:55:54+00:00", + "create_time": "2021-07-26T15:33:42+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "control_back": { + "type": "Boolean", + "value": {} + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["cancel", "1", "2", "3", "4"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "time_total": { + "type": "Integer", + "value": { + "unit": "ms", + "min": 0, + "max": 120000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "open", + "percent_control": 0, + "percent_state": 0, + "control_back": true, + "work_state": "opening", + "countdown": "cancel", + "countdown_left": 0, + "time_total": 25400 + }, + "terminal_id": "REDACTED" +} diff --git a/tests/components/tuya/fixtures/cl_cpbo62rn.json b/tests/components/tuya/fixtures/cl_cpbo62rn.json new file mode 100644 index 00000000000..a5ed8e4b580 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_cpbo62rn.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf216113c71bf01a18jtl0", + "name": "blinds", + "category": "cl", + "product_id": "cpbo62rn", + "product_name": "curtain robot", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2023-06-29T15:14:19+00:00", + "create_time": "2023-06-29T15:14:19+00:00", + "update_time": "2023-06-29T15:14:19+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "percent_state": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["morning", "night"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["motor_fault"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "control": "stop", + "percent_control": 63, + "percent_state": 64, + "mode": "morning", + "fault": 0, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json new file mode 100644 index 00000000000..4b15a27bfd5 --- /dev/null +++ b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json @@ -0,0 +1,58 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kitchen Blinds", + "model": "KASMARTBLIA", + "category": "cl", + "product_id": "ebt12ypvexnixvtf", + "product_name": "Smart Blinds", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2022-01-13T23:10:34+00:00", + "create_time": "2022-01-13T23:10:34+00:00", + "update_time": "2022-02-12T10:40:15+00:00", + "function": { + "switch_1": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "percent_control": 0 + }, + "terminal_id": "REDACTED" +} diff --git a/tests/components/tuya/fixtures/cl_qqdxfdht.json b/tests/components/tuya/fixtures/cl_qqdxfdht.json new file mode 100644 index 00000000000..b8f568619db --- /dev/null +++ b/tests/components/tuya/fixtures/cl_qqdxfdht.json @@ -0,0 +1,67 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb9c4958fd06d141djpqa", + "name": "bedroom blinds", + "category": "cl", + "product_id": "qqdxfdht", + "product_name": "Blinds Drive-BLE", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2021-11-09T08:38:29+00:00", + "create_time": "2021-11-09T08:38:29+00:00", + "update_time": "2021-11-09T08:38:29+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close", "continue"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": "0", + "max": "100", + "scale": "0", + "step": "1" + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["opening", "closing"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "work_state": "closing" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index aa592b25520..0c556a90494 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,4 +1,208 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-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.lounge_dark_blind_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.mocked_device_idcontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Lounge Dark Blind Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.lounge_dark_blind_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-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.blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bf216113c71bf01a18jtl0control', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 36, + 'device_class': 'curtain', + 'friendly_name': 'blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-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.kitchen_blinds_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blind', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'blind', + 'unique_id': 'tuya.mocked_device_idswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'Kitchen Blinds Blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-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.bedroom_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bfb9c4958fd06d141djpqacontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'bedroom blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.bedroom_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index db9964974bd..98e3174b077 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'morning', + 'night', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.blinds_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_mode', + 'unique_id': 'tuya.bf216113c71bf01a18jtl0mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'blinds Mode', + 'options': list([ + 'morning', + 'night', + ]), + }), + 'context': , + 'entity_id': 'select.blinds_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'morning', + }) +# --- # name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index fcd14667d36..7d209439dc5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-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.lounge_dark_blind_last_operation_duration', + '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': 'Last operation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_operation_duration', + 'unique_id': 'tuya.mocked_device_idtime_total', + 'unit_of_measurement': 'ms', + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Last operation duration', + 'unit_of_measurement': 'ms', + }), + 'context': , + 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25400.0', + }) +# --- # name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index fbbf68d2634..d483d852f1a 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-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': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + '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': 'Reverse', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse', + 'unique_id': 'tuya.mocked_device_idcontrol_back', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Reverse', + }), + 'context': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c30ee776e97745ef93dc51b21474ac3b33e5d6b9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:44:51 +0200 Subject: [PATCH 0988/1118] Add Tuya snapshots tests for zwjcy category (soil sensor) (#150168) --- tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/zwjcy_myd45weu.json | 79 +++++++ .../tuya/snapshots/test_sensor.ambr | 210 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 tests/components/tuya/fixtures/zwjcy_myd45weu.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 6fd429d6391..1def19a06bd 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -467,6 +467,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, ], + "zwjcy_myd45weu": [ + # https://github.com/orgs/home-assistant/discussions/482 + Platform.SENSOR, + ], } diff --git a/tests/components/tuya/fixtures/zwjcy_myd45weu.json b/tests/components/tuya/fixtures/zwjcy_myd45weu.json new file mode 100644 index 00000000000..3ea111abb0e --- /dev/null +++ b/tests/components/tuya/fixtures/zwjcy_myd45weu.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf1a0431555359ce06ie0z", + "name": "Patates", + "category": "zwjcy", + "product_id": "myd45weu", + "product_name": "Soil sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:12:41+00:00", + "create_time": "2025-07-19T12:12:41+00:00", + "update_time": "2025-07-19T12:12:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -30, + "max": 70, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "humidity": 97, + "temp_current": 22, + "temp_unit_convert": "c", + "battery_state": "low", + "battery_percentage": 20 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 7d209439dc5..fade1fcbc2b 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4950,3 +4950,213 @@ 'state': '233.8', }) # --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patates Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.patates_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patates Battery state', + }), + 'context': , + 'entity_id': 'sensor.patates_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf1a0431555359ce06ie0zhumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Patates Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf1a0431555359ce06ie0ztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Patates Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.patates_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- From 4f5502ab47d4e4c1d88d26330a3cf4c72c788bed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:50:46 +0200 Subject: [PATCH 0989/1118] Add Tuya snapshots tests for ldcg category (luminance sensor) (#150169) --- tests/components/tuya/__init__.py | 4 + .../tuya/fixtures/ldcg_9kbbfeho.json | 47 ++++++++ .../tuya/snapshots/test_sensor.ambr | 106 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 tests/components/tuya/fixtures/ldcg_9kbbfeho.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1def19a06bd..816845df7ef 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -282,6 +282,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/pull/148646 Platform.CLIMATE, ], + "ldcg_9kbbfeho": [ + # https://github.com/orgs/home-assistant/discussions/482 + Platform.SENSOR, + ], "mal_gyitctrjj1kefxp2": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, diff --git a/tests/components/tuya/fixtures/ldcg_9kbbfeho.json b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json new file mode 100644 index 00000000000..223e39a00d4 --- /dev/null +++ b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json @@ -0,0 +1,47 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfbc8a692eaeeef455fkct", + "name": "Luminosité", + "category": "ldcg", + "product_id": "9kbbfeho", + "product_name": "Luminance sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T16:37:21+00:00", + "create_time": "2025-07-25T16:37:21+00:00", + "update_time": "2025-07-25T16:37:21+00:00", + "function": {}, + "status_range": { + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "bright_value": 16, + "battery_percentage": 91 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index fade1fcbc2b..f19c360dbb9 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1872,6 +1872,112 @@ 'state': '220.4', }) # --- +# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_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.luminosite_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfbc8a692eaeeef455fkctbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Luminosité Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.luminosite_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '91.0', + }) +# --- +# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_illuminance-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.luminosite_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.bfbc8a692eaeeef455fkctbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Luminosité Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.luminosite_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- # name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From df7c657d7e9cf79211fddb535a60b27529044f9b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:53:19 +0200 Subject: [PATCH 0990/1118] Add Tuya snapshots tests for wk category (thermostat) (#150175) --- tests/components/tuya/__init__.py | 25 ++- .../components/tuya/fixtures/wk_6kijc7nd.json | 189 ++++++++++++++++ .../tuya/fixtures/wk_gogb05wrtredz3bs.json | 197 +++++++++++++++++ .../tuya/fixtures/wk_y5obtqhuztqsf2mj.json | 76 +++++++ .../tuya/snapshots/test_climate.ambr | 209 ++++++++++++++++++ .../tuya/snapshots/test_number.ambr | 116 ++++++++++ .../tuya/snapshots/test_switch.ambr | 144 ++++++++++++ 7 files changed, 952 insertions(+), 4 deletions(-) create mode 100644 tests/components/tuya/fixtures/wk_6kijc7nd.json create mode 100644 tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json create mode 100644 tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 816845df7ef..076dc7f745e 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -423,15 +423,21 @@ DEVICE_MOCKS = { "wfcon_b25mh8sxawsgndck": [ # https://github.com/home-assistant/core/issues/149704 ], + "wg2_nwxr8qcu4seltoro": [ + # https://github.com/orgs/home-assistant/discussions/430 + Platform.BINARY_SENSOR, + ], + "wk_6kijc7nd": [ + # https://github.com/home-assistant/core/issues/136513 + Platform.CLIMATE, + Platform.NUMBER, + Platform.SWITCH, + ], "wk_aqoouq7x": [ # https://github.com/home-assistant/core/issues/146263 Platform.CLIMATE, Platform.SWITCH, ], - "wg2_nwxr8qcu4seltoro": [ - # https://github.com/orgs/home-assistant/discussions/430 - Platform.BINARY_SENSOR, - ], "wk_fi6dne5tu4t1nm6j": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, @@ -439,6 +445,17 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "wk_gogb05wrtredz3bs": [ + # https://github.com/home-assistant/core/issues/136337 + Platform.CLIMATE, + Platform.NUMBER, + Platform.SWITCH, + ], + "wk_y5obtqhuztqsf2mj": [ + # https://github.com/home-assistant/core/issues/139735 + Platform.CLIMATE, + Platform.SWITCH, + ], "wsdcg_g2y6z3p3ja2qhyav": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/wk_6kijc7nd.json b/tests/components/tuya/fixtures/wk_6kijc7nd.json new file mode 100644 index 00000000000..068a9b676a7 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_6kijc7nd.json @@ -0,0 +1,189 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3c2c83660b8e19e152jb", + "name": "Кабінет", + "category": "wk", + "product_id": "6kijc7nd", + "product_name": "Thermostat Tervix Pro Line ZigBee color", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-01-22T11:54:29+00:00", + "create_time": "2025-01-22T11:54:29+00:00", + "update_time": "2025-01-22T11:54:29+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "program"] + } + }, + "window_check": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 50, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 350, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "program"] + } + }, + "window_check": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 50, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 350, + "max": 950, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 995, + "scale": 1, + "step": 5 + } + }, + "window_state": { + "type": "Enum", + "value": { + "range": ["close", "open"] + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "℃", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status": { + "switch": true, + "mode": "manual", + "window_check": false, + "frost": false, + "temp_set": 215, + "upper_temp": 450, + "temp_current": 195, + "window_state": "close", + "temp_correction": -2, + "humidity": 23, + "factory_reset": false, + "child_lock": false, + "sensor_choose": "all" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json new file mode 100644 index 00000000000..bac85a54ed2 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json @@ -0,0 +1,197 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf1085bf049a74fcc1idy2", + "name": "smart thermostats", + "category": "wk", + "product_id": "gogb05wrtredz3bs", + "product_name": "smart thermostats", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-23T13:07:21+00:00", + "create_time": "2025-01-23T13:07:21+00:00", + "update_time": "2025-01-23T13:07:21+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 30, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "摄氏度", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 30, + "max": 90, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 900, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "摄氏度", + "min": -9, + "max": 9, + "scale": 0, + "step": 1 + } + }, + "valve_state": { + "type": "Enum", + "value": { + "range": ["open", "close"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "sensor_choose": { + "type": "Enum", + "value": { + "range": ["in", "out"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2", "e3"] + } + } + }, + "status": { + "switch": false, + "mode": "manual", + "frost": true, + "temp_set": 12, + "upper_temp": 30, + "temp_current": 215, + "lower_temp": 5, + "temp_correction": -2, + "valve_state": "close", + "factory_reset": false, + "child_lock": false, + "sensor_choose": "in", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json new file mode 100644 index 00000000000..352a0ded392 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json @@ -0,0 +1,76 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf8d64588f4a61965ezszs", + "name": "Term - Prizemi", + "category": "wk", + "product_id": "y5obtqhuztqsf2mj", + "product_name": "Smart", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-02-22T10:25:25+00:00", + "create_time": "2025-02-22T10:25:25+00:00", + "update_time": "2025-02-22T10:25:25+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 5, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 900, + "scale": 1, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "temp_set": 230, + "temp_current": 230, + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index cb535cc5c07..bdca800576e 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,81 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][climate.kabinet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 95.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'program', + ]), + '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.kabinet', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf3c2c83660b8e19e152jb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][climate.kabinet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Кабінет', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 95.0, + 'min_temp': 5.0, + 'preset_mode': None, + 'preset_modes': list([ + 'program', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.kabinet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- # name: test_platform_setup_and_discovery[wk_aqoouq7x][climate.clima_cucina-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -221,3 +296,137 @@ 'state': 'heat_cool', }) # --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][climate.smart_thermostats-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 90.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.smart_thermostats', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf1085bf049a74fcc1idy2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][climate.smart_thermostats-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'smart thermostats', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 90.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 12.0, + }), + 'context': , + 'entity_id': 'climate.smart_thermostats', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][climate.term_prizemi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 0.5, + '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.term_prizemi', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf8d64588f4a61965ezszs', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][climate.term_prizemi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.0, + 'friendly_name': 'Term - Prizemi', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 70.0, + 'min_temp': 0.5, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.term_prizemi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index b5d6224ecea..dbb928711f8 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -759,6 +759,64 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][number.kabinet_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kabinet_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.bf3c2c83660b8e19e152jbtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][number.kabinet_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.kabinet_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -817,6 +875,64 @@ 'state': '-1.5', }) # --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][number.smart_thermostats_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.bf1085bf049a74fcc1idy2temp_correction', + 'unit_of_measurement': '摄氏度', + }) +# --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][number.smart_thermostats_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '摄氏度', + }), + 'context': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- # name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index d483d852f1a..55fbf272bca 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -3339,6 +3339,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][switch.kabinet_child_lock-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': , + 'entity_id': 'switch.kabinet_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf3c2c83660b8e19e152jbchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_6kijc7nd][switch.kabinet_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Child lock', + }), + 'context': , + 'entity_id': 'switch.kabinet_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_aqoouq7x][switch.clima_cucina_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3435,3 +3483,99 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][switch.smart_thermostats_child_lock-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': , + 'entity_id': 'switch.smart_thermostats_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf1085bf049a74fcc1idy2child_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][switch.smart_thermostats_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Child lock', + }), + 'context': , + 'entity_id': 'switch.smart_thermostats_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][switch.term_prizemi_child_lock-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': , + 'entity_id': 'switch.term_prizemi_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf8d64588f4a61965ezszschild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][switch.term_prizemi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Term - Prizemi Child lock', + }), + 'context': , + 'entity_id': 'switch.term_prizemi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- From e96e97edcadf91f8b84314a7ab31a6c7d61ac3d3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:24:33 +0200 Subject: [PATCH 0991/1118] Add Tuya snapshots tests for sj category (rain sensor) (#150173) --- tests/components/tuya/__init__.py | 5 ++ .../components/tuya/fixtures/sj_tgvtvdoc.json | 43 +++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 tests/components/tuya/fixtures/sj_tgvtvdoc.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 076dc7f745e..2062fbe8433 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -362,6 +362,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SIREN, ], + "sj_tgvtvdoc": [ + # https://github.com/orgs/home-assistant/discussions/482 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], "sp_drezasavompxpcgm": [ # https://github.com/home-assistant/core/issues/149704 Platform.CAMERA, diff --git a/tests/components/tuya/fixtures/sj_tgvtvdoc.json b/tests/components/tuya/fixtures/sj_tgvtvdoc.json new file mode 100644 index 00000000000..a63fd7af508 --- /dev/null +++ b/tests/components/tuya/fixtures/sj_tgvtvdoc.json @@ -0,0 +1,43 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf58e095fd2d86d592tveh", + "name": "Tournesol", + "category": "sj", + "product_id": "tgvtvdoc", + "product_name": "Rain sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-30T04:51:12+00:00", + "create_time": "2025-07-30T04:51:12+00:00", + "update_time": "2025-07-30T04:51:12+00:00", + "function": {}, + "status_range": { + "watersensor_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "watersensor_state": "normal", + "battery_percentage": 98 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 6ae0b4997dd..5524657227d 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -685,6 +685,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[sj_tgvtvdoc][binary_sensor.tournesol_moisture-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.tournesol_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf58e095fd2d86d592tvehwatersensor_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sj_tgvtvdoc][binary_sensor.tournesol_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Tournesol Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.tournesol_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f19c360dbb9..514136c73ed 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -3879,6 +3879,59 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[sj_tgvtvdoc][sensor.tournesol_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.tournesol_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf58e095fd2d86d592tvehbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sj_tgvtvdoc][sensor.tournesol_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Tournesol Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tournesol_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.0', + }) +# --- # name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From b835b7f2660b96ad6c2dc90e32475dca0ba6a8c6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 7 Aug 2025 13:31:55 +0200 Subject: [PATCH 0992/1118] Bump imgw_pib to version 1.5.3 (#150178) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index e65ccf35fb5..145690487d7 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.2"] + "requirements": ["imgw_pib==1.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3edf7b654ef..9243c9a0f73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1240,7 +1240,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.2 +imgw_pib==1.5.3 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a75ee0920f2..615c98b9b58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.14 # homeassistant.components.imgw_pib -imgw_pib==1.5.2 +imgw_pib==1.5.3 # homeassistant.components.incomfort incomfort-client==0.6.9 From d99379ffdf45eadc2c3f4f328657383d8f84541a Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 7 Aug 2025 15:11:00 +0200 Subject: [PATCH 0993/1118] modbus: use only 1 logger instance. (#150130) --- homeassistant/components/modbus/binary_sensor.py | 4 +--- homeassistant/components/modbus/climate.py | 4 +--- homeassistant/components/modbus/const.py | 3 +++ homeassistant/components/modbus/entity.py | 4 +--- homeassistant/components/modbus/light.py | 2 -- homeassistant/components/modbus/modbus.py | 3 +-- homeassistant/components/modbus/sensor.py | 5 +---- 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 28d1be24587..a7e2cd51a65 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity @@ -24,6 +23,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_hub from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT, @@ -32,8 +32,6 @@ from .const import ( from .entity import BasePlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index be10a9495c6..1138734b5bf 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import struct from typing import Any, cast @@ -44,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_COIL, @@ -104,8 +104,6 @@ from .const import ( from .entity import BaseStructPlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY = { diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 068a46b1f81..ada56c46f79 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -1,6 +1,7 @@ """Constants used in modbus integration.""" from enum import Enum +import logging from homeassistant.const import ( CONF_ADDRESS, @@ -177,3 +178,5 @@ LIGHT_MAX_BRIGHTNESS = 255 LIGHT_MODBUS_SCALE_MIN = 0 LIGHT_MODBUS_SCALE_MAX = 100 LIGHT_MODBUS_INVALID_VALUE = 0xFFFF + +_LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 53c3e8f8709..10ce211fc25 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -6,7 +6,6 @@ from abc import abstractmethod import asyncio from collections.abc import Callable from datetime import datetime, timedelta -import logging import struct from typing import Any, cast @@ -33,6 +32,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.restore_state import RestoreEntity from .const import ( + _LOGGER, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -68,8 +68,6 @@ from .const import ( ) from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - class BasePlatform(Entity): """Base for readonly platforms.""" diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index c025eefe0e4..7b1035c702b 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.light import ( @@ -35,7 +34,6 @@ from .entity import BaseSwitch from .modbus import ModbusHub PARALLEL_UPDATES = 1 -_LOGGER = logging.getLogger(__name__) async def async_setup_platform( diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 1304e679347..6f1a4bfd5b1 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from collections import namedtuple from collections.abc import Callable -import logging from typing import Any from pymodbus.client import ( @@ -38,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import ( + _LOGGER, ATTR_ADDRESS, ATTR_HUB, ATTR_SLAVE, @@ -70,7 +70,6 @@ from .const import ( ) from .validators import check_config -_LOGGER = logging.getLogger(__name__) DATA_MODBUS_HUBS: HassKey[dict[str, ModbusHub]] = HassKey(DOMAIN) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 490aece587c..767fed5fceb 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from homeassistant.components.sensor import ( @@ -26,12 +25,10 @@ from homeassistant.helpers.update_coordinator import ( ) from . import get_hub -from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT +from .const import _LOGGER, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT from .entity import BaseStructPlatform from .modbus import ModbusHub -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 From 448084e2b52f5e4fbb3130c70f11100f7a5f676d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 7 Aug 2025 15:22:36 +0200 Subject: [PATCH 0994/1118] Fix description of `button.press` action (#150181) --- homeassistant/components/button/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index f552e9ae12b..49a70ba9ffa 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -25,7 +25,7 @@ "services": { "press": { "name": "Press", - "description": "Press the button entity." + "description": "Presses a button entity." } } } From d778afe61afebbb0b66d653963d61da2ae669a79 Mon Sep 17 00:00:00 2001 From: "Stefan H." <34062375+BlackBadPinguin@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:33:24 +0200 Subject: [PATCH 0995/1118] Fix Enigma2 startup hang (#149756) --- homeassistant/components/enigma2/coordinator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index 9710d7f547f..02e50c2cc06 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -1,5 +1,6 @@ """Data update coordinator for the Enigma2 integration.""" +import asyncio import logging from openwebif.api import OpenWebIfDevice, OpenWebIfStatus @@ -30,6 +31,8 @@ from .const import CONF_SOURCE_BOUQUET, DOMAIN LOGGER = logging.getLogger(__package__) +SETUP_TIMEOUT = 10 + type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] @@ -79,7 +82,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): async def _async_setup(self) -> None: """Provide needed data to the device info.""" - about = await self.device.get_about() + about = await asyncio.wait_for(self.device.get_about(), timeout=SETUP_TIMEOUT) self.device.mac_address = about["info"]["ifaces"][0]["mac"] self.device_info["model"] = about["info"]["model"] self.device_info["manufacturer"] = about["info"]["brand"] From ff9e2a8f1ee2cf56a61909b2ab30605ce70b0cc8 Mon Sep 17 00:00:00 2001 From: yufeng Date: Thu, 7 Aug 2025 23:08:57 +0800 Subject: [PATCH 0996/1118] Update tuya translation for reverse energy sensor (#149317) --- homeassistant/components/tuya/sensor.py | 2 +- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/zndb_4ggkyflayu1h1ho9.json | 218 ++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 280 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++ 5 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9eb05186f63..dce08024ca3 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1334,7 +1334,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.REVERSE_ENERGY_TOTAL, - translation_key="total_energy", + translation_key="total_production", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 2062fbe8433..e4bdffd73b6 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -489,6 +489,11 @@ DEVICE_MOCKS = { Platform.NUMBER, Platform.SENSOR, ], + "zndb_4ggkyflayu1h1ho9": [ + # https://github.com/home-assistant/core/pull/149317 + Platform.SENSOR, + Platform.SWITCH, + ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json new file mode 100644 index 00000000000..dc1d2143087 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json @@ -0,0 +1,218 @@ +{ + "endpoint": "https://apigw.tuyacn.com", + "terminal_id": "1753864737914eTkTk2", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "6c0887b46a2eaf56e0ui7d", + "name": "XOCA-DAC212XC V2-S1", + "category": "zndb", + "product_id": "4ggkyflayu1h1ho9", + "product_name": "XOCA-DAC212XC V2-S1", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2025-07-07T10:32:35+00:00", + "create_time": "2025-07-07T10:32:35+00:00", + "update_time": "2025-07-07T10:32:35+00:00", + "function": { + "frozen_time_set": { + "type": "Json", + "value": {} + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_2": { + "type": "Json", + "value": {} + }, + "event_clear": { + "type": "Boolean", + "value": {} + }, + "price_set": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Json", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "terminal_alarm", + "cover_alarm", + "credit_alarm", + "no_balance_alarm", + "battery_alarm", + "meter_hardware_alarm", + "overdraft_unlim", + "arrear_outage", + "overdraft_use", + "pf_abnormal", + "ov_pwr" + ] + } + }, + "frozen_time_set": { + "type": "Json", + "value": {} + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_2": { + "type": "Json", + "value": {} + }, + "meter_id": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "event_clear": { + "type": "Boolean", + "value": {} + }, + "forward_energy_t1": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t2": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t3": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "forward_energy_t4": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "price_set": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["offline", "online"] + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "forward_energy_total": 120, + "reverse_energy_total": 80, + "phase_a": { + "electricCurrent": 599.552, + "power": 6.912, + "voltage": 52.7 + }, + "fault": 0, + "frozen_time_set": { + "day": 158, + "hour": 233 + }, + "switch_prepayment": false, + "clear_energy": false, + "switch": true, + "alarm_set_2": [], + "meter_id": "", + "event_clear": false, + "forward_energy_t1": 0, + "forward_energy_t2": 0, + "forward_energy_t3": 0, + "forward_energy_t4": 0, + "price_set": "", + "online_state": "offline", + "supply_frequency": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 514136c73ed..483a6e6c3f5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4941,6 +4941,286 @@ 'state': 'upper_alarm', }) # --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_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.xoca_dac212xc_v2_s1_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '599.552', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_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.xoca_dac212xc_v2_s1_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.912', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_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.xoca_dac212xc_v2_s1_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.7', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_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.xoca_dac212xc_v2_s1_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_production-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.xoca_dac212xc_v2_s1_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- # name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 55fbf272bca..1b8cd9b807c 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -3579,3 +3579,51 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][switch.xoca_dac212xc_v2_s1_switch-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.xoca_dac212xc_v2_s1_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][switch.xoca_dac212xc_v2_s1_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'XOCA-DAC212XC V2-S1 Switch', + }), + 'context': , + 'entity_id': 'switch.xoca_dac212xc_v2_s1_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- From 704edac9fd311ade3b03e5753798fde8a3a62655 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 7 Aug 2025 18:42:53 +0200 Subject: [PATCH 0997/1118] Remove deprecated state from backup schedule (#150114) Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/config.py | 24 ------------------- homeassistant/components/backup/websocket.py | 3 --- .../backup/snapshots/test_diagnostics.ambr | 1 - .../backup/snapshots/test_store.ambr | 7 ------ .../backup/snapshots/test_websocket.ambr | 16 ------------- tests/components/backup/test_websocket.py | 17 +------------ 6 files changed, 1 insertion(+), 67 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0c8a5c82f7c..e4feb7dd8bd 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -127,7 +127,6 @@ class BackupConfigData: schedule=BackupSchedule( days=days, recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]), - state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)), time=time, ), ) @@ -453,7 +452,6 @@ class StoredBackupSchedule(TypedDict): days: list[Day] recurrence: ScheduleRecurrence - state: ScheduleState time: str | None @@ -462,7 +460,6 @@ class ScheduleParametersDict(TypedDict, total=False): days: list[Day] recurrence: ScheduleRecurrence - state: ScheduleState time: dt.time | None @@ -486,32 +483,12 @@ class ScheduleRecurrence(StrEnum): CUSTOM_DAYS = "custom_days" -class ScheduleState(StrEnum): - """Represent the schedule recurrence. - - This is deprecated and can be remove in HA Core 2025.8. - """ - - NEVER = "never" - DAILY = "daily" - MONDAY = "mon" - TUESDAY = "tue" - WEDNESDAY = "wed" - THURSDAY = "thu" - FRIDAY = "fri" - SATURDAY = "sat" - SUNDAY = "sun" - - @dataclass(kw_only=True) class BackupSchedule: """Represent the backup schedule.""" days: list[Day] = field(default_factory=list) recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER - # Although no longer used, state is kept for backwards compatibility. - # It can be removed in HA Core 2025.8. - state: ScheduleState = ScheduleState.NEVER time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) next_automatic_backup: datetime | None = field(init=False, default=None) @@ -610,7 +587,6 @@ class BackupSchedule: return StoredBackupSchedule( days=self.days, recurrence=self.recurrence, - state=self.state, time=self.time.isoformat() if self.time else None, ) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 3e6b13bfb56..d7e9b600155 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -331,9 +331,6 @@ async def handle_config_info( """Send the stored backup config.""" manager = hass.data[DATA_MANAGER] config = manager.config.data.to_dict() - # Remove state from schedule, it's not needed in the frontend - # mypy doesn't like deleting from TypedDict, ignore it - del config["schedule"]["state"] # type: ignore[misc] connection.send_result( msg["id"], { diff --git a/tests/components/backup/snapshots/test_diagnostics.ambr b/tests/components/backup/snapshots/test_diagnostics.ambr index cf412970204..a1ee55f07f1 100644 --- a/tests/components/backup/snapshots/test_diagnostics.ambr +++ b/tests/components/backup/snapshots/test_diagnostics.ambr @@ -31,7 +31,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index aa9ccde4b8a..b82bb7c650f 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -88,7 +88,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -187,7 +186,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -306,7 +304,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -413,7 +410,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -520,7 +516,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -633,7 +628,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -758,7 +752,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 31e7fa0ee5b..2bac144a258 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1306,7 +1306,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -1423,7 +1422,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -1540,7 +1538,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -1671,7 +1668,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -1949,7 +1945,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2064,7 +2059,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2179,7 +2173,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2296,7 +2289,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': '06:00:00', }), }), @@ -2415,7 +2407,6 @@ 'mon', ]), 'recurrence': 'custom_days', - 'state': 'never', 'time': None, }), }), @@ -2532,7 +2523,6 @@ 'days': list([ ]), 'recurrence': 'never', - 'state': 'never', 'time': None, }), }), @@ -2653,7 +2643,6 @@ 'sun', ]), 'recurrence': 'custom_days', - 'state': 'never', 'time': None, }), }), @@ -2778,7 +2767,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -2895,7 +2883,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3012,7 +2999,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3129,7 +3115,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), @@ -3246,7 +3231,6 @@ 'days': list([ ]), 'recurrence': 'daily', - 'state': 'never', 'time': None, }), }), diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 02e40cabb33..ba19abdbb34 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -76,7 +76,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": {"days": [], "recurrence": "never", "state": "never", "time": None}, + "schedule": {"days": [], "recurrence": "never", "time": None}, }, } DAILY = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] @@ -1009,7 +1009,6 @@ async def test_agents_info( "schedule": { "days": DAILY, "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1041,7 +1040,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1073,7 +1071,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1105,7 +1102,6 @@ async def test_agents_info( "schedule": { "days": ["mon"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1137,7 +1133,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1169,7 +1164,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1204,7 +1198,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1236,7 +1229,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1268,7 +1260,6 @@ async def test_agents_info( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -1309,7 +1300,6 @@ async def test_agents_info( "schedule": { "days": ["mon", "sun"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, @@ -1960,7 +1950,6 @@ async def test_config_schedule_logic( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -2870,7 +2859,6 @@ async def test_config_retention_copies_logic( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -3149,7 +3137,6 @@ async def test_config_retention_copies_logic_manual_backup( "schedule": { "days": [], "recurrence": "daily", - "state": "never", "time": None, }, }, @@ -3814,7 +3801,6 @@ async def test_config_retention_days_logic( "schedule": { "days": [], "recurrence": "never", - "state": "never", "time": None, }, }, @@ -3886,7 +3872,6 @@ async def test_configured_agents_unavailable_repair( "schedule": { "days": ["mon"], "recurrence": "custom_days", - "state": "never", "time": None, }, }, From b638fcbaad93edac47ab1803bb2ed88159dd3c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 7 Aug 2025 18:42:22 +0100 Subject: [PATCH 0998/1118] Bump hass-nabucasa from 0.111.1 to 0.111.2 (#150209) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 76e55bc19b3..cb3537a59e5 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.111.1"], + "requirements": ["hass-nabucasa==0.111.2"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 28e7491c48c..1a2f0d182a4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.2 -hass-nabucasa==0.111.1 +hass-nabucasa==0.111.2 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250806.0 diff --git a/pyproject.toml b/pyproject.toml index 0125d5b1bbc..f8af19868bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.111.1", + "hass-nabucasa==0.111.2", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index af9a835e0d9..7bd900a69ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.111.1 +hass-nabucasa==0.111.2 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9243c9a0f73..7e560ad5e09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ habiticalib==0.4.1 habluetooth==4.0.2 # homeassistant.components.cloud -hass-nabucasa==0.111.1 +hass-nabucasa==0.111.2 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 615c98b9b58..544f9133c50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ habiticalib==0.4.1 habluetooth==4.0.2 # homeassistant.components.cloud -hass-nabucasa==0.111.1 +hass-nabucasa==0.111.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 6aa077a48d9a85b6f3906b5ebd243a433d4f82bf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 7 Aug 2025 19:43:36 +0200 Subject: [PATCH 0999/1118] Silence vacuum battery deprecation for built in integrations (#150204) --- homeassistant/components/vacuum/__init__.py | 4 +- tests/components/vacuum/test_init.py | 103 +++++++++++--------- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 11db9108db3..eb8789779a7 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -333,7 +333,7 @@ class StateVacuumEntity( f"is setting the {property} which has been deprecated." f" Integration {self.platform.platform_name} should implement a sensor" " instead with a correct device class and link it to the same device", - core_integration_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.IGNORE, custom_integration_behavior=ReportBehavior.LOG, breaks_in_ha_version="2026.8", integration_domain=self.platform.platform_name, @@ -358,7 +358,7 @@ class StateVacuumEntity( f" Integration {self.platform.platform_name} should remove this as part of migrating" " the battery level and icon to a sensor", core_behavior=ReportBehavior.LOG, - core_integration_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.IGNORE, custom_integration_behavior=ReportBehavior.LOG, breaks_in_ha_version="2026.8", integration_domain=self.platform.platform_name, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 60ff0a1ebde..92fbca483fd 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +import logging from types import ModuleType from typing import Any @@ -437,11 +438,13 @@ async def test_vacuum_deprecated_state_does_not_break_state( assert state.state == "cleaning" -@pytest.mark.usefixtures("mock_as_custom_component") -async def test_vacuum_log_deprecated_battery_properties( +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) +async def test_vacuum_log_deprecated_battery_using_properties( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, ) -> None: """Test incorrectly using battery properties logs warning.""" @@ -449,7 +452,7 @@ async def test_vacuum_log_deprecated_battery_properties( """Mocked vacuum entity.""" @property - def activity(self) -> str: + def activity(self) -> VacuumActivity: """Return the state of the entity.""" return VacuumActivity.CLEANING @@ -477,7 +480,7 @@ async def test_vacuum_log_deprecated_battery_properties( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), - built_in=False, + built_in=is_built_in, ) setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -486,26 +489,27 @@ async def test_vacuum_log_deprecated_battery_properties( assert state is not None assert ( - "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it" - " to the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" - in caplog.text + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings ) + assert ( - "Detected that custom integration 'test' is setting the battery_level which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it" - " to the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" + "integration 'test' is setting the battery_icon which has been deprecated." in caplog.text - ) + ) != is_built_in + assert ( + "integration 'test' is setting the battery_level which has been deprecated." + in caplog.text + ) != is_built_in -@pytest.mark.usefixtures("mock_as_custom_component") -async def test_vacuum_log_deprecated_battery_properties_using_attr( +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 3)]) +async def test_vacuum_log_deprecated_battery_using_attr( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, ) -> None: """Test incorrectly using _attr_battery_* attribute does log issue and raise repair.""" @@ -531,7 +535,7 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), - built_in=False, + built_in=is_built_in, ) setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -541,47 +545,51 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr( entity.start() assert ( - "Detected that custom integration 'test' is setting the battery_level which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" - in caplog.text + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings ) + assert ( - "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" + "integration 'test' is setting the battery_level which has been deprecated." in caplog.text - ) + ) != is_built_in + assert ( + "integration 'test' is setting the battery_icon which has been deprecated." + in caplog.text + ) != is_built_in await async_start(hass, entity.entity_id) caplog.clear() + await async_start(hass, entity.entity_id) + # Test we only log once assert ( - "Detected that custom integration 'test' is setting the battery_level which has been deprecated." - not in caplog.text - ) - assert ( - "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." - not in caplog.text + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == 0 ) -@pytest.mark.usefixtures("mock_as_custom_component") +@pytest.mark.parametrize(("is_built_in", "log_warnings"), [(True, 0), (False, 1)]) async def test_vacuum_log_deprecated_battery_supported_feature( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, + is_built_in: bool, + log_warnings: int, ) -> None: """Test incorrectly setting battery supported feature logs warning.""" - entity = MockVacuum( - name="Testing", - entity_id="vacuum.test", - ) + class MockVacuum(StateVacuumEntity): + """Mock vacuum class.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE | VacuumEntityFeature.BATTERY + ) + _attr_name = "Testing" + + entity = MockVacuum() config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) @@ -592,7 +600,7 @@ async def test_vacuum_log_deprecated_battery_supported_feature( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), - built_in=False, + built_in=is_built_in, ) setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -601,13 +609,14 @@ async def test_vacuum_log_deprecated_battery_supported_feature( assert state is not None assert ( - "Detected that custom integration 'test' is setting the battery supported feature" - " which has been deprecated. Integration test should remove this as part of migrating" - " the battery level and icon to a sensor. This will stop working in Home Assistant 2026.8" - ", please report it to the author of the 'test' custom integration" - in caplog.text + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == log_warnings ) + assert ( + "integration 'test' is setting the battery supported feature" in caplog.text + ) != is_built_in + async def test_vacuum_not_log_deprecated_battery_properties_during_init( hass: HomeAssistant, @@ -624,7 +633,7 @@ async def test_vacuum_not_log_deprecated_battery_properties_during_init( self._attr_battery_level = 50 @property - def activity(self) -> str: + def activity(self) -> VacuumActivity: """Return the state of the entity.""" return VacuumActivity.CLEANING @@ -635,6 +644,6 @@ async def test_vacuum_not_log_deprecated_battery_properties_during_init( assert entity.battery_level == 50 assert ( - "Detected that custom integration 'test' is setting the battery_level which has been deprecated." - not in caplog.text + len([record for record in caplog.records if record.levelno >= logging.WARNING]) + == 0 ) From 382bf78ee010f0f54c61cbe0719fa42029e0a584 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 7 Aug 2025 20:11:39 +0200 Subject: [PATCH 1000/1118] Ignore MQTT vacuum battery warning (#150211) --- homeassistant/components/vacuum/__init__.py | 5 ++++- tests/components/mqtt/test_vacuum.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index eb8789779a7..081b7a15995 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -79,7 +79,10 @@ DEFAULT_NAME = "Vacuum cleaner robot" _DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(VacuumActivity.IDLE, "2026.1") _DEPRECATED_STATE_PAUSED = DeprecatedConstantEnum(VacuumActivity.PAUSED, "2026.1") -_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",) +_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ( + "mqtt", + "template", +) class VacuumEntityFeature(IntFlag): diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 77b90403823..b0c5981fbe1 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -2,6 +2,7 @@ from copy import deepcopy import json +import logging from typing import Any from unittest.mock import patch @@ -395,6 +396,15 @@ async def test_status_with_deprecated_battery_feature( assert issue.issue_domain == "vacuum" assert issue.translation_key == "deprecated_vacuum_battery_feature" assert issue.translation_placeholders == {"entity_id": "vacuum.mqtttest"} + assert not [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert ( + "mqtt' is setting the battery_level which has been deprecated" + ) not in caplog.text @pytest.mark.parametrize( From fd0ae32058ed2f440809ba5d6bd2b7cef28753f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 7 Aug 2025 20:48:25 +0200 Subject: [PATCH 1001/1118] Bump pymiele to 0.5.3 (#150216) --- homeassistant/components/miele/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index c9a20e977f9..b8ca0535c3e 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -8,7 +8,7 @@ "iot_class": "cloud_push", "loggers": ["pymiele"], "quality_scale": "bronze", - "requirements": ["pymiele==0.5.2"], + "requirements": ["pymiele==0.5.3"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e560ad5e09..cbb9610f294 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.2 +pymiele==0.5.3 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 544f9133c50..8071731f86e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1788,7 +1788,7 @@ pymeteoclimatic==0.1.0 pymicro-vad==1.0.1 # homeassistant.components.miele -pymiele==0.5.2 +pymiele==0.5.3 # homeassistant.components.mochad pymochad==0.2.0 From cbaadebac315ae7226571656b5a5e174d75d49bb Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 7 Aug 2025 22:39:24 +0200 Subject: [PATCH 1002/1118] Fix Tibber coordinator ContextVar warning (#150229) --- homeassistant/components/tibber/sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 327812cdf99..1c56d5b2ce6 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -299,7 +299,10 @@ async def async_setup_entry( ) await home.rt_subscribe( TibberRtDataCoordinator( - entity_creator.add_sensors, home, hass + hass, + entry, + entity_creator.add_sensors, + home, ).async_set_updated_data ) @@ -613,15 +616,17 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en def __init__( self, + hass: HomeAssistant, + config_entry: ConfigEntry, add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], tibber_home: tibber.TibberHome, - hass: HomeAssistant, ) -> None: """Initialize the data handler.""" self._add_sensor_callback = add_sensor_callback super().__init__( hass, _LOGGER, + config_entry=config_entry, name=tibber_home.info["viewer"]["home"]["address"].get( "address1", "Tibber" ), From ba0da4c2a37ce226012fea11e8f2b7a96eb88abf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 7 Aug 2025 22:39:45 +0200 Subject: [PATCH 1003/1118] Remove switchbot vacuum battery attribute (#150227) --- homeassistant/components/switchbot/vacuum.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/switchbot/vacuum.py b/homeassistant/components/switchbot/vacuum.py index 9dade6b7f46..8535fdc7843 100644 --- a/homeassistant/components/switchbot/vacuum.py +++ b/homeassistant/components/switchbot/vacuum.py @@ -87,8 +87,7 @@ class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): _device: switchbot.SwitchbotVacuum _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START | VacuumEntityFeature.STATE ) @@ -108,11 +107,6 @@ class SwitchbotVacuumEntity(SwitchbotEntity, StateVacuumEntity): status_code = self._device.get_work_status() return SWITCHBOT_VACUUM_STATE_MAP[self.protocol_version].get(status_code) - @property - def battery_level(self) -> int: - """Return the vacuum battery.""" - return self._device.get_battery() - async def async_start(self) -> None: """Start or resume the cleaning task.""" self._last_run_success = bool( From 71485871c8e696340532847e6d256d1260c9790d Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Thu, 7 Aug 2025 22:59:58 +0200 Subject: [PATCH 1004/1118] Bump Huum requirement to 0.8.1 (#150220) --- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 38001c58b35..79bfd9795cb 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.8.0"] + "requirements": ["huum==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cbb9610f294..fab1f283f66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1192,7 +1192,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.8.0 +huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8071731f86e..3e80789dbb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1038,7 +1038,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.8.0 +huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 From 3ab80c6ff23ae138c269a1b5ac64c6369b88e8a8 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 8 Aug 2025 02:26:02 +0300 Subject: [PATCH 1005/1118] Bump google-genai to 1.29.0 (#150225) --- .../__init__.py | 6 ++- .../config_flow.py | 2 +- .../entity.py | 44 +++++++++++-------- .../manifest.json | 2 +- .../google_generative_ai_conversation/tts.py | 32 ++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../__init__.py | 33 ++------------ .../snapshots/test_init.ambr | 20 +++++++-- .../test_conversation.py | 3 ++ .../test_tts.py | 2 +- 11 files changed, 87 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3c1c9cad0b0..a1fd5ea0f9b 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -124,7 +124,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" ) - if not response.candidates[0].content.parts: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + ): raise HomeAssistantError("Unknown error generating content") return {"text": response.text} diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index e760187bc66..9048304a006 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -377,7 +377,7 @@ async def google_generative_ai_config_option_schema( value=api_model.name, ) for api_model in sorted( - api_models, key=lambda x: x.name.lstrip("models/") or "" + api_models, key=lambda x: (x.name or "").lstrip("models/") ) if ( api_model.name diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 8e967d84517..90c144530e0 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import codecs -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Callable from dataclasses import replace import mimetypes from pathlib import Path @@ -15,6 +15,7 @@ from google.genai.errors import APIError, ClientError from google.genai.types import ( AutomaticFunctionCallingConfig, Content, + ContentDict, File, FileState, FunctionDeclaration, @@ -23,9 +24,11 @@ from google.genai.types import ( GoogleSearch, HarmCategory, Part, + PartUnionDict, SafetySetting, Schema, Tool, + ToolListUnion, ) import voluptuous as vol from voluptuous_openapi import convert @@ -237,7 +240,7 @@ def _convert_content( async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], + result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True try: @@ -342,7 +345,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[Tool | Callable[..., Any]] | None = None + tools: ToolListUnion | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -373,7 +376,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): else: raise HomeAssistantError("Invalid prompt content") - messages: list[Content] = [] + messages: list[Content | ContentDict] = [] # Google groups tool results, we do not. Group them before sending. tool_results: list[conversation.ToolResultContent] = [] @@ -400,7 +403,10 @@ class GoogleGenerativeAILLMBaseEntity(Entity): # The SDK requires the first message to be a user message # This is not the case if user used `start_conversation` # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": + if messages and ( + (isinstance(messages[0], Content) and messages[0].role != "user") + or (isinstance(messages[0], dict) and messages[0]["role"] != "user") + ): messages.insert( 0, Content(role="user", parts=[Part.from_text(text=" ")]), @@ -440,14 +446,14 @@ class GoogleGenerativeAILLMBaseEntity(Entity): ) user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) - chat_request: str | list[Part] = user_message.content + chat_request: list[PartUnionDict] = [user_message.content] if user_message.attachments: files = await async_prepare_files_for_prompt( self.hass, self._genai_client, [a.path for a in user_message.attachments], ) - chat_request = [chat_request, *files] + chat_request = [*chat_request, *files] # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): @@ -464,15 +470,17 @@ class GoogleGenerativeAILLMBaseEntity(Entity): error = ERROR_GETTING_RESPONSE raise HomeAssistantError(error) from err - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] + chat_request = list( + _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) ) if not chat_log.unresponded_tool_results: @@ -559,13 +567,13 @@ async def async_prepare_files_for_prompt( await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) uploaded_file = await client.aio.files.get( - name=uploaded_file.name, + name=uploaded_file.name or "", config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) if uploaded_file.state == FileState.FAILED: raise HomeAssistantError( - f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message if uploaded_file.error else 'unknown'}" ) prompt_parts = await hass.async_add_executor_job(upload_files) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 25e44964a6d..ce089440b97 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.7.0"] + "requirements": ["google-genai==1.29.0"] } diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 08e83242fcd..ed956bdb13c 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -146,15 +146,41 @@ class GoogleGenerativeAITextToSpeechEntity( ) ) ) + + def _extract_audio_parts( + response: types.GenerateContentResponse, + ) -> tuple[bytes, str]: + if ( + not response.candidates + or not response.candidates[0].content + or not response.candidates[0].content.parts + or not response.candidates[0].content.parts[0].inline_data + ): + raise ValueError("No content returned from TTS generation") + + data = response.candidates[0].content.parts[0].inline_data.data + mime_type = response.candidates[0].content.parts[0].inline_data.mime_type + + if not isinstance(data, bytes): + raise TypeError( + f"Expected bytes for audio data, got {type(data).__name__}" + ) + if not isinstance(mime_type, str): + raise TypeError( + f"Expected str for mime_type, got {type(mime_type).__name__}" + ) + + return data, mime_type + try: response = await self._genai_client.aio.models.generate_content( model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), contents=message, config=config, ) - data = response.candidates[0].content.parts[0].inline_data.data - mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except (APIError, ClientError, ValueError) as exc: + + data, mime_type = _extract_audio_parts(response) + except (APIError, ClientError, ValueError, TypeError) as exc: LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc return "wav", convert_to_wav(data, mime_type) diff --git a/requirements_all.txt b/requirements_all.txt index fab1f283f66..1e27ad4ded9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1057,7 +1057,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e80789dbb1..caf90997fa5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -924,7 +924,7 @@ google-cloud-speech==2.31.1 google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation -google-genai==1.7.0 +google-genai==1.29.0 # homeassistant.components.google_travel_time google-maps-routing==0.6.15 diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 18b3c8e07f0..57119ce0ff1 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -1,43 +1,16 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import Mock - from google.genai.errors import APIError, ClientError -import httpx API_ERROR_500 = APIError( 500, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Internal Server Error", - "status": "internal-error", - } - ), - ), + {"message": "Internal Server Error", "status": "internal-error"}, ) CLIENT_ERROR_BAD_REQUEST = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "Bad Request", - "status": "invalid-argument", - } - ), - ), + {"message": "Bad Request", "status": "invalid-argument"}, ) CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, - Mock( - __class__=httpx.Response, - json=Mock( - return_value={ - "message": "'reason': API_KEY_INVALID", - "status": "unauthorized", - } - ), - ), + {"message": "'reason': API_KEY_INVALID", "status": "unauthorized"}, ) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index d0b92a7e88d..c2568159c79 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -128,8 +128,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), - File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), @@ -145,8 +151,14 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), - File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File( + name='doorbell_snapshot.jpg', + state= + ), + File( + name='context.txt', + state= + ), ]), 'model': 'models/gemini-2.5-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 90f496b4b5b..ab8c10e933b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -195,10 +195,13 @@ async def test_function_call( "response": { "result": "Test response", }, + "scheduling": None, + "will_continue": None, }, "inline_data": None, "text": None, "thought": None, + "thought_signature": None, "video_metadata": None, } diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 108ac82947c..87fc4fe8a76 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -37,7 +37,7 @@ from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator -API_ERROR_500 = APIError("test", response=MagicMock()) +API_ERROR_500 = APIError("test", response_json={}) TEST_CHAT_MODEL = "models/some-tts-model" From 52f0d04c38500eacec1db039c2057d0c7c707d1a Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 8 Aug 2025 05:32:05 +0200 Subject: [PATCH 1006/1118] Improve Roborock test teardown (#150144) --- tests/components/roborock/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 994f58513d2..6974bc5fccc 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -199,7 +199,7 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" with patch("homeassistant.components.roborock.roborock_storage"): From a88eadf8632761555fbc28c808159c15fae42ec5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 7 Aug 2025 23:40:28 -0700 Subject: [PATCH 1007/1118] Update Opower strings (#150247) --- homeassistant/components/opower/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 5bb22699220..c2cd4227da0 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -10,7 +10,7 @@ } }, "credentials": { - "title": "Enter Credentials", + "title": "Enter credentials", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", @@ -34,7 +34,7 @@ }, "mfa_code": { "title": "Enter security code", - "description": "A security code has been sent via your selected method. Please enter it below to complete login.", + "description": "Please enter the security code below to complete login.", "data": { "mfa_code": "Security code" }, From fd6aba3022f028c771c86486e8454bd6246c1685 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Thu, 7 Aug 2025 20:41:03 -1000 Subject: [PATCH 1008/1118] Add missing strings for APCUPSD (#150242) --- homeassistant/components/apcupsd/strings.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index d821b66ef67..8f237fd41fe 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -14,7 +14,22 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of the APC UPS Daemon", + "port": "The port the APC UPS Daemon is listening on" + }, "description": "Enter the host and port on which the apcupsd NIS is being served." + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::apcupsd::config::step::user::data_description::host%]", + "port": "[%key:component::apcupsd::config::step::user::data_description::port%]" + }, + "description": "[%key:component::apcupsd::config::step::user::description%]" } } }, From 102d6a37c03909cd612983fd7c74a8f318f8b9d1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:15:42 +0200 Subject: [PATCH 1009/1118] Use generated device id in tuya tests (#150196) --- tests/components/tuya/conftest.py | 5 +- .../snapshots/test_alarm_control_panel.ambr | 2 +- .../tuya/snapshots/test_binary_sensor.ambr | 34 +-- .../tuya/snapshots/test_button.ambr | 10 +- .../tuya/snapshots/test_camera.ambr | 6 +- .../tuya/snapshots/test_climate.ambr | 12 +- .../components/tuya/snapshots/test_cover.ambr | 12 +- .../tuya/snapshots/test_diagnostics.ambr | 4 +- .../components/tuya/snapshots/test_event.ambr | 4 +- tests/components/tuya/snapshots/test_fan.ambr | 18 +- .../tuya/snapshots/test_humidifier.ambr | 8 +- .../components/tuya/snapshots/test_init.ambr | 2 +- .../components/tuya/snapshots/test_light.ambr | 70 +++--- .../tuya/snapshots/test_number.ambr | 48 ++-- .../tuya/snapshots/test_select.ambr | 54 ++--- .../tuya/snapshots/test_sensor.ambr | 208 +++++++++--------- .../components/tuya/snapshots/test_siren.ambr | 6 +- .../tuya/snapshots/test_switch.ambr | 150 ++++++------- .../tuya/snapshots/test_vacuum.ambr | 2 +- 19 files changed, 329 insertions(+), 326 deletions(-) diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 73752590637..b563e7d5241 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -145,7 +145,10 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev hass, f"{mock_device_code}.json", DOMAIN ) device = MagicMock(spec=CustomerDevice) - device.id = details.get("id", "mocked_device_id") + + # Use reverse of the product_id for testing + device.id = mock_device_code.replace("_", "")[::-1] + device.name = details["name"] device.category = details["category"] device.product_id = details["product_id"] diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr index 73072dcb516..38c7f04f9d9 100644 --- a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.123123aba12312312dazubmaster_mode', + 'unique_id': 'tuya.2pxfek1jjrtctiyglammaster_mode', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 5524657227d..210ede6da09 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinco2_state', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occo2_state', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'defrost', - 'unique_id': 'tuya.mock_device_iddefrost', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscdefrost', 'unit_of_measurement': None, }) # --- @@ -128,7 +128,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tankfull', - 'unique_id': 'tuya.mock_device_idtankfull', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksctankfull', 'unit_of_measurement': None, }) # --- @@ -177,7 +177,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'defrost', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqdefrost', + 'unique_id': 'tuya.2myxayqtud9aqbizscdefrost', 'unit_of_measurement': None, }) # --- @@ -226,7 +226,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'tankfull', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqtankfull', + 'unique_id': 'tuya.2myxayqtud9aqbizsctankfull', 'unit_of_measurement': None, }) # --- @@ -275,7 +275,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wet', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqwet', + 'unique_id': 'tuya.2myxayqtud9aqbizscwet', 'unit_of_measurement': None, }) # --- @@ -324,7 +324,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf78687ad321a3aeb8a73mpresence_state', + 'unique_id': 'tuya.kxwleaa2sphpresence_state', 'unit_of_measurement': None, }) # --- @@ -373,7 +373,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf5cccf9027080e2dbb9w3switch', + 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmswitch', 'unit_of_measurement': None, }) # --- @@ -422,7 +422,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.73486068483fda10d633pir', + 'unique_id': 'tuya.hkm4px9ohzozxma3rippir', 'unit_of_measurement': None, }) # --- @@ -471,7 +471,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.73486068483fda10d633temper_alarm', + 'unique_id': 'tuya.hkm4px9ohzozxma3riptemper_alarm', 'unit_of_measurement': None, }) # --- @@ -520,7 +520,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf445324326cbde7c5rg7bpir', + 'unique_id': 'tuya.s3zzjdcfrippir', 'unit_of_measurement': None, }) # --- @@ -569,7 +569,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf445324326cbde7c5rg7btemper_alarm', + 'unique_id': 'tuya.s3zzjdcfriptemper_alarm', 'unit_of_measurement': None, }) # --- @@ -618,7 +618,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.20401777500291cfe3a2pir', + 'unique_id': 'tuya.zoytcemodrn39zqwrippir', 'unit_of_measurement': None, }) # --- @@ -667,7 +667,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_status', + 'unique_id': 'tuya.cwwk68dyfsh2eqi4jbqrgas_sensor_status', 'unit_of_measurement': None, }) # --- @@ -716,7 +716,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf58e095fd2d86d592tvehwatersensor_state', + 'unique_id': 'tuya.codvtvgtjswatersensor_state', 'unit_of_measurement': None, }) # --- @@ -765,7 +765,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf79ca977d67322eb2o68mmaster_state', + 'unique_id': 'tuya.orotles4ucq8rxwn2gwmaster_state', 'unit_of_measurement': None, }) # --- @@ -814,7 +814,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.8670375210521cf1349csmoke_sensor_status', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwysmoke_sensor_status', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr index 61b62e124e5..d7a6d7fa401 100644 --- a/tests/components/tuya/snapshots/test_button.ambr +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_duster_cloth', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_duster_cloth', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_duster_cloth', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_edge_brush', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_edge_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_edge_brush', 'unit_of_measurement': None, }) # --- @@ -126,7 +126,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_filter', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_filter', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_filter', 'unit_of_measurement': None, }) # --- @@ -174,7 +174,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_map', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_map', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_map', 'unit_of_measurement': None, }) # --- @@ -222,7 +222,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_roll_brush', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtreset_roll_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsreset_roll_brush', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr index e1945f03d3c..f2ad466fdd2 100644 --- a/tests/components/tuya/snapshots/test_camera.ambr +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfm', + 'unique_id': 'tuya.mgcpxpmovasazerdps', 'unit_of_measurement': None, }) # --- @@ -84,7 +84,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrps', 'unit_of_measurement': None, }) # --- @@ -137,7 +137,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf3f8b448bbc123e29oghf', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddsps', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index bdca800576e..6f8ffafc7a6 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -42,7 +42,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.mock_device_id', + 'unique_id': 'tuya.mvsdcwtskkezlnw5tk', 'unit_of_measurement': None, }) # --- @@ -117,7 +117,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf3c2c83660b8e19e152jb', + 'unique_id': 'tuya.dn7cjik6kw', 'unit_of_measurement': None, }) # --- @@ -195,7 +195,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf6fc1645146455a2efrex', + 'unique_id': 'tuya.x7quooqakw', 'unit_of_measurement': None, }) # --- @@ -269,7 +269,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bfb45cb8a9452fba66lexg', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkw', 'unit_of_measurement': None, }) # --- @@ -336,7 +336,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf1085bf049a74fcc1idy2', + 'unique_id': 'tuya.sb3zdertrw50bgogkw', 'unit_of_measurement': None, }) # --- @@ -403,7 +403,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf8d64588f4a61965ezszs', + 'unique_id': 'tuya.jm2fsqtzuhqtbo5ykw', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 0c556a90494..560c4cd58ff 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'curtain', - 'unique_id': 'tuya.mocked_device_idcontrol', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol', 'unit_of_measurement': None, }) # --- @@ -81,7 +81,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'curtain', - 'unique_id': 'tuya.bf216113c71bf01a18jtl0control', + 'unique_id': 'tuya.nr26obpclccontrol', 'unit_of_measurement': None, }) # --- @@ -132,7 +132,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'blind', - 'unique_id': 'tuya.mocked_device_idswitch_1', + 'unique_id': 'tuya.ftvxinxevpy21tbelcswitch_1', 'unit_of_measurement': None, }) # --- @@ -183,7 +183,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'curtain', - 'unique_id': 'tuya.bfb9c4958fd06d141djpqacontrol', + 'unique_id': 'tuya.thdfxdqqlccontrol', 'unit_of_measurement': None, }) # --- @@ -234,7 +234,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'curtain', - 'unique_id': 'tuya.zah67ekdcontrol', + 'unique_id': 'tuya.dke76hazlccontrol', 'unit_of_measurement': None, }) # --- @@ -285,7 +285,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'curtain', - 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8control', + 'unique_id': 'tuya.2w46jyhngklccontrol', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr index 93cc0cd0b6d..33248655d31 100644 --- a/tests/components/tuya/snapshots/test_diagnostics.ambr +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -59,7 +59,7 @@ 'name': 'Gas sensor', 'name_by_user': None, }), - 'id': 'ebb9d0eb5014f98cfboxbz', + 'id': 'cwwk68dyfsh2eqi4jbqr', 'mqtt_connected': True, 'name': 'Gas sensor', 'online': True, @@ -147,7 +147,7 @@ 'name': 'Gas sensor', 'name_by_user': None, }), - 'id': 'ebb9d0eb5014f98cfboxbz', + 'id': 'cwwk68dyfsh2eqi4jbqr', 'name': 'Gas sensor', 'online': True, 'product_id': '4iqe2hsfyd86kwwc', diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index ea19ff486da..f5c03f9d7a3 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -35,7 +35,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'numbered_button', - 'unique_id': 'tuya.mocked_device_idswitch_mode1', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwswitch_mode1', 'unit_of_measurement': None, }) # --- @@ -94,7 +94,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'numbered_button', - 'unique_id': 'tuya.mocked_device_idswitch_mode2', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwswitch_mode2', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 7532023860b..57fa3f1e345 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -33,7 +33,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.mock_device_id', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksc', 'unit_of_measurement': None, }) # --- @@ -85,7 +85,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.28403630e8db84b7a963', + 'unique_id': 'tuya.hz4pau766eavmxhqsc', 'unit_of_measurement': None, }) # --- @@ -135,7 +135,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.mock_device_id', + 'unique_id': 'tuya.ilms5pwjzzsxuxmvsc', 'unit_of_measurement': None, }) # --- @@ -185,7 +185,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf3fce6af592f12df3gbgq', + 'unique_id': 'tuya.2myxayqtud9aqbizsc', 'unit_of_measurement': None, }) # --- @@ -240,7 +240,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.XXX', + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsf', 'unit_of_measurement': None, }) # --- @@ -299,7 +299,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.10706550a4e57c88b93a', + 'unique_id': 'tuya.c1tfgunpf6optybisf', 'unit_of_measurement': None, }) # --- @@ -351,7 +351,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.152027113c6105cce49c', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjk', 'unit_of_measurement': None, }) # --- @@ -409,7 +409,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.CENSORED', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjk', 'unit_of_measurement': None, }) # --- @@ -468,7 +468,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.mock_device_id', + 'unique_id': 'tuya.lflvu8cazha8af9jsk', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 33034e3f6e7..c58d06e6888 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -33,7 +33,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.mock_device_idswitch', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscswitch', 'unit_of_measurement': None, }) # --- @@ -88,7 +88,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.28403630e8db84b7a963switch', + 'unique_id': 'tuya.hz4pau766eavmxhqscswitch', 'unit_of_measurement': None, }) # --- @@ -143,7 +143,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.mock_device_idswitch', + 'unique_id': 'tuya.ilms5pwjzzsxuxmvscswitch', 'unit_of_measurement': None, }) # --- @@ -198,7 +198,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqswitch', + 'unique_id': 'tuya.2myxayqtud9aqbizscswitch', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 83548abf0c3..1075b68ec5e 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -15,7 +15,7 @@ 'identifiers': set({ tuple( 'tuya', - 'mock_device_id', + 'e2sbdwuga5jorvejtkdy', ), }), 'labels': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 4f2f22ddf2b..e27ead0f022 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -34,7 +34,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backlight', - 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8switch_backlight', + 'unique_id': 'tuya.2w46jyhngklcswitch_backlight', 'unit_of_measurement': None, }) # --- @@ -96,7 +96,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfd9f45c6b882c9f46dxfcswitch_led', + 'unique_id': 'tuya.x4nogasbi8ggpb3lcdswitch_led', 'unit_of_measurement': None, }) # --- @@ -158,7 +158,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.eb10549aadfc74b7c8q2tiswitch_led', + 'unique_id': 'tuya.klgxmpwvdhw7tzs8jdswitch_led', 'unit_of_measurement': None, }) # --- @@ -224,7 +224,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf71858c3d27943679dsx9switch_led', + 'unique_id': 'tuya.w8oht6v8aauqa0y8jdswitch_led', 'unit_of_measurement': None, }) # --- @@ -295,7 +295,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.40611462e09806c73134switch_led', + 'unique_id': 'tuya.z7cu5t8bl9tt9fabjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -359,7 +359,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf671413db4cee1f9bqdcxswitch_led', + 'unique_id': 'tuya.6o148laaosbf0g4djdswitch_led', 'unit_of_measurement': None, }) # --- @@ -424,7 +424,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf8885f3d18a73e395bfacswitch_led', + 'unique_id': 'tuya.z8woiryqydmzonjdjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -507,7 +507,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfb99bba00c9c90ba8gzglswitch_led', + 'unique_id': 'tuya.sj55nxhjftilowkejdswitch_led', 'unit_of_measurement': None, }) # --- @@ -572,7 +572,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf0914a82b06ecf151xsf5switch_led', + 'unique_id': 'tuya.ajkdo1bm2rcmpuufjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -644,7 +644,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.00450321483fda81c529switch_led', + 'unique_id': 'tuya.vnj3sa6mqahro6phjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -727,7 +727,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.63362034840d8eb9029fswitch_led', + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -795,7 +795,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_light', - 'unique_id': 'tuya.63362034840d8eb9029fswitch_1', + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_1', 'unit_of_measurement': None, }) # --- @@ -857,7 +857,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf0fc1d7d4caa71a59us7cswitch_led', + 'unique_id': 'tuya.7jxnjpiltmj2zyaijdswitch_led', 'unit_of_measurement': None, }) # --- @@ -923,7 +923,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf599f5cffe1a5985depykswitch_led', + 'unique_id': 'tuya.aoyweq8xbx7qfndijdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1005,7 +1005,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.84178216d8f15be52dc4switch_led', + 'unique_id': 'tuya.86kdcut3hiqqddlijdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1087,7 +1087,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfe49d7b6cd80536efdldiswitch_led', + 'unique_id': 'tuya.buzituffc13pgb1jjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1159,7 +1159,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.07608286600194e94248switch_led', + 'unique_id': 'tuya.trffx1ktlyu3tnmljdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1227,7 +1227,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.REDACTEDswitch_led', + 'unique_id': 'tuya.r4yrlr705ei31ikmjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1294,7 +1294,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf77c04cbd6a52a7be16llswitch_led', + 'unique_id': 'tuya.ijne16zv8vpqmubnjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1359,7 +1359,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.40350105dc4f229a464eswitch_led', + 'unique_id': 'tuya.6gsqieoh1yzjvxlnjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1431,7 +1431,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf8d8af3ddfe75b0195r0hswitch_led', + 'unique_id': 'tuya.gjnpc0eojdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1504,7 +1504,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf46b2b81ca41ce0c1xpswswitch_led', + 'unique_id': 'tuya.97k3pwirjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1586,7 +1586,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf252b8ee16b2e78bdoxlpswitch_led', + 'unique_id': 'tuya.ngcubvaqoraolsmtjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1650,7 +1650,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf8edbd51a52c01a4bfgqfswitch_led', + 'unique_id': 'tuya.rdq0bn4dzuwx2qfujdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1715,7 +1715,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfd56f4718874ee8830xdwswitch_led', + 'unique_id': 'tuya.bak2crzmabancwqvjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1780,7 +1780,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfc1ef4da4accc0731oggwswitch_led', + 'unique_id': 'tuya.kkande5hk6sfdkoxjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1861,7 +1861,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.03010850c44f33966362switch_led', + 'unique_id': 'tuya.nxdcy0uidplnhkazjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1926,7 +1926,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.500425642462ab50909bswitch_led', + 'unique_id': 'tuya.87yarxyp23ap1vazjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -1995,7 +1995,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf74164049de868395pbciswitch_led', + 'unique_id': 'tuya.yky6kunazmaitupzjdswitch_led', 'unit_of_measurement': None, }) # --- @@ -2061,7 +2061,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.XXXlight', + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsflight', 'unit_of_measurement': None, }) # --- @@ -2143,7 +2143,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.eb3e988f33c233290cfs3lswitch_led', + 'unique_id': 'tuya.nt3mpibadxfqkegldygswitch_led', 'unit_of_measurement': None, }) # --- @@ -2211,7 +2211,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'backlight', - 'unique_id': 'tuya.mock_device_idlight', + 'unique_id': 'tuya.lflvu8cazha8af9jsklight', 'unit_of_measurement': None, }) # --- @@ -2268,7 +2268,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_indicator', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_indicator', 'unit_of_measurement': None, }) # --- @@ -2325,7 +2325,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_indicator', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_indicator', 'unit_of_measurement': None, }) # --- @@ -2382,7 +2382,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.bfdb773e4ae317e3915h2iswitch_led', + 'unique_id': 'tuya.couukaypjdnytswitch_led', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index dbb928711f8..48256bab849 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -35,7 +35,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_duration', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_time', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_time', 'unit_of_measurement': , }) # --- @@ -94,7 +94,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'feed', - 'unique_id': 'tuya.bfd0273e59494eb34esvrxmanual_feed', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcmanual_feed', 'unit_of_measurement': '', }) # --- @@ -152,7 +152,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'far_detection', - 'unique_id': 'tuya.bf78687ad321a3aeb8a73mfar_detection', + 'unique_id': 'tuya.kxwleaa2sphfar_detection', 'unit_of_measurement': 'cm', }) # --- @@ -211,7 +211,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'near_detection', - 'unique_id': 'tuya.bf78687ad321a3aeb8a73mnear_detection', + 'unique_id': 'tuya.kxwleaa2sphnear_detection', 'unit_of_measurement': 'cm', }) # --- @@ -270,7 +270,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sensitivity', - 'unique_id': 'tuya.bf78687ad321a3aeb8a73msensitivity', + 'unique_id': 'tuya.kxwleaa2sphsensitivity', 'unit_of_measurement': 'x', }) # --- @@ -328,7 +328,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_delay', - 'unique_id': 'tuya.123123aba12312312dazubalarm_delay_time', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_delay_time', 'unit_of_measurement': 's', }) # --- @@ -387,7 +387,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'arm_delay', - 'unique_id': 'tuya.123123aba12312312dazubdelay_set', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamdelay_set', 'unit_of_measurement': 's', }) # --- @@ -446,7 +446,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'siren_duration', - 'unique_id': 'tuya.123123aba12312312dazubalarm_time', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_time', 'unit_of_measurement': 'min', }) # --- @@ -505,7 +505,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cook_temperature', - 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_temperature', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_temperature', 'unit_of_measurement': '℃', }) # --- @@ -563,7 +563,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cook_time', - 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_time', 'unit_of_measurement': , }) # --- @@ -621,7 +621,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtvolume_set', + 'unique_id': 'tuya.zrrraytdoanz33rldsvolume_set', 'unit_of_measurement': '%', }) # --- @@ -679,7 +679,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time', - 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_time', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_time', 'unit_of_measurement': '', }) # --- @@ -737,7 +737,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_device_volume', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_device_volume', 'unit_of_measurement': '', }) # --- @@ -795,7 +795,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_correction', - 'unique_id': 'tuya.bf3c2c83660b8e19e152jbtemp_correction', + 'unique_id': 'tuya.dn7cjik6kwtemp_correction', 'unit_of_measurement': '℃', }) # --- @@ -853,7 +853,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_correction', - 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwtemp_correction', 'unit_of_measurement': '℃', }) # --- @@ -911,7 +911,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temp_correction', - 'unique_id': 'tuya.bf1085bf049a74fcc1idy2temp_correction', + 'unique_id': 'tuya.sb3zdertrw50bgogkwtemp_correction', 'unit_of_measurement': '摄氏度', }) # --- @@ -969,7 +969,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_maximum', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4max_set', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymax_set', 'unit_of_measurement': '%', }) # --- @@ -1027,7 +1027,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_minimum', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4mini_set', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymini_set', 'unit_of_measurement': '%', }) # --- @@ -1085,7 +1085,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'installation_height', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4installation_height', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyinstallation_height', 'unit_of_measurement': 'm', }) # --- @@ -1144,7 +1144,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maximum_liquid_depth', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth_max', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth_max', 'unit_of_measurement': 'm', }) # --- @@ -1203,7 +1203,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_maximum', - 'unique_id': 'tuya.mocked_device_idmax_set', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwymax_set', 'unit_of_measurement': '%', }) # --- @@ -1261,7 +1261,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'alarm_minimum', - 'unique_id': 'tuya.mocked_device_idmini_set', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwymini_set', 'unit_of_measurement': '%', }) # --- @@ -1319,7 +1319,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'installation_height', - 'unique_id': 'tuya.mocked_device_idinstallation_height', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyinstallation_height', 'unit_of_measurement': 'm', }) # --- @@ -1378,7 +1378,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'maximum_liquid_depth', - 'unique_id': 'tuya.mocked_device_idliquid_depth_max', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth_max', 'unit_of_measurement': 'm', }) # --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 98e3174b077..571f8358870 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -35,7 +35,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'curtain_mode', - 'unique_id': 'tuya.bf216113c71bf01a18jtl0mode', + 'unique_id': 'tuya.nr26obpclcmode', 'unit_of_measurement': None, }) # --- @@ -92,7 +92,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'curtain_motor_mode', - 'unique_id': 'tuya.zah67ekdcontrol_back_mode', + 'unique_id': 'tuya.dke76hazlccontrol_back_mode', 'unit_of_measurement': None, }) # --- @@ -151,7 +151,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_volume', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_volume', 'unit_of_measurement': None, }) # --- @@ -212,7 +212,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'countdown', - 'unique_id': 'tuya.mock_device_idcountdown_set', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksccountdown_set', 'unit_of_measurement': None, }) # --- @@ -273,7 +273,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'countdown', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqcountdown_set', + 'unique_id': 'tuya.2myxayqtud9aqbizsccountdown_set', 'unit_of_measurement': None, }) # --- @@ -332,7 +332,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'odor_elimination_mode', - 'unique_id': 'tuya.bf6574iutyikgwkxwork_mode', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_mode', 'unit_of_measurement': None, }) # --- @@ -390,7 +390,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'light_mode', - 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2elight_mode', + 'unique_id': 'tuya.pdasfna8fswh4a0tzclight_mode', 'unit_of_measurement': None, }) # --- @@ -449,7 +449,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', - 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2erelay_status', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcrelay_status', 'unit_of_measurement': None, }) # --- @@ -510,7 +510,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'countdown', - 'unique_id': 'tuya.XXXcountdown_set', + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsfcountdown_set', 'unit_of_measurement': None, }) # --- @@ -574,7 +574,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'countdown', - 'unique_id': 'tuya.CENSOREDcountdown_set', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkcountdown_set', 'unit_of_measurement': None, }) # --- @@ -637,7 +637,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vacuum_mode', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtmode', + 'unique_id': 'tuya.zrrraytdoanz33rldsmode', 'unit_of_measurement': None, }) # --- @@ -697,7 +697,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'vacuum_cistern', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtcistern', + 'unique_id': 'tuya.zrrraytdoanz33rldscistern', 'unit_of_measurement': None, }) # --- @@ -757,7 +757,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'volume', - 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_volume', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_volume', 'unit_of_measurement': None, }) # --- @@ -817,7 +817,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmmotion_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_sensitivity', 'unit_of_measurement': None, }) # --- @@ -876,7 +876,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'basic_nightvision', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_nightvision', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_nightvision', 'unit_of_measurement': None, }) # --- @@ -934,7 +934,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'record_mode', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmrecord_mode', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_mode', 'unit_of_measurement': None, }) # --- @@ -991,7 +991,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'decibel_sensitivity', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmdecibel_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_sensitivity', 'unit_of_measurement': None, }) # --- @@ -1049,7 +1049,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9motion_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_sensitivity', 'unit_of_measurement': None, }) # --- @@ -1107,7 +1107,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'record_mode', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9record_mode', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_mode', 'unit_of_measurement': None, }) # --- @@ -1164,7 +1164,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'decibel_sensitivity', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9decibel_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_sensitivity', 'unit_of_measurement': None, }) # --- @@ -1221,7 +1221,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ipc_work_mode', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfipc_work_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsipc_work_mode', 'unit_of_measurement': None, }) # --- @@ -1279,7 +1279,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_sensitivity', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_sensitivity', 'unit_of_measurement': None, }) # --- @@ -1337,7 +1337,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'record_mode', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_mode', 'unit_of_measurement': None, }) # --- @@ -1395,7 +1395,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', - 'unique_id': 'tuya.bfa008a4f82a56616c69uzrelay_status', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtrelay_status', 'unit_of_measurement': None, }) # --- @@ -1454,7 +1454,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', - 'unique_id': 'tuya.bff35871a2f4430058vs8urelay_status', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtrelay_status', 'unit_of_measurement': None, }) # --- @@ -1513,7 +1513,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', - 'unique_id': 'tuya.bf082711d275c0c883vb4prelay_status', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtrelay_status', 'unit_of_measurement': None, }) # --- @@ -1572,7 +1572,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'relay_status', - 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unrelay_status', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtrelay_status', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 483a6e6c3f5..2fb7f2a0ed0 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_operation_duration', - 'unique_id': 'tuya.mocked_device_idtime_total', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lctime_total', 'unit_of_measurement': 'ms', }) # --- @@ -81,7 +81,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinbattery_percentage', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -134,7 +134,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'formaldehyde', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinch2o_value', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2occh2o_value', 'unit_of_measurement': 'mg/m3', }) # --- @@ -186,7 +186,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinhumidity_value', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ochumidity_value', 'unit_of_measurement': '%', }) # --- @@ -242,7 +242,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpintemp_current', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2octemp_current', 'unit_of_measurement': , }) # --- @@ -295,7 +295,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voc', - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinvoc_value', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocvoc_value', 'unit_of_measurement': 'mg/m³', }) # --- @@ -348,7 +348,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.mock_device_idhumidity_indoor', + 'unique_id': 'tuya.ifzgvpgoodrfw2akschumidity_indoor', 'unit_of_measurement': '%', }) # --- @@ -401,7 +401,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqhumidity_indoor', + 'unique_id': 'tuya.2myxayqtud9aqbizschumidity_indoor', 'unit_of_measurement': '%', }) # --- @@ -454,7 +454,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf6574iutyikgwkxbattery_percentage', + 'unique_id': 'tuya.rl39uwgaqwjwcbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -505,7 +505,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'odor_elimination_status', - 'unique_id': 'tuya.bf6574iutyikgwkxwork_state_e', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_state_e', 'unit_of_measurement': None, }) # --- @@ -555,7 +555,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_amount', - 'unique_id': 'tuya.bfd0273e59494eb34esvrxfeed_report', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcfeed_report', 'unit_of_measurement': '', }) # --- @@ -610,7 +610,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_duration', - 'unique_id': 'tuya.23536058083a8dc57d96filter_life', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_life', 'unit_of_measurement': 'min', }) # --- @@ -666,7 +666,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_runtime', - 'unique_id': 'tuya.23536058083a8dc57d96uv_runtime', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv_runtime', 'unit_of_measurement': 's', }) # --- @@ -717,7 +717,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_level_state', - 'unique_id': 'tuya.23536058083a8dc57d96water_level', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_level', 'unit_of_measurement': None, }) # --- @@ -770,7 +770,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'pump_time', - 'unique_id': 'tuya.23536058083a8dc57d96pump_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_time', 'unit_of_measurement': 'min', }) # --- @@ -826,7 +826,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_time', - 'unique_id': 'tuya.23536058083a8dc57d96water_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_time', 'unit_of_measurement': 'min', }) # --- @@ -885,7 +885,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current', - 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_current', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_current', 'unit_of_measurement': , }) # --- @@ -941,7 +941,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_power', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_power', 'unit_of_measurement': 'W', }) # --- @@ -1000,7 +1000,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', - 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_voltage', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_voltage', 'unit_of_measurement': , }) # --- @@ -1059,7 +1059,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current', - 'unique_id': 'tuya.051724052462ab286504cur_current', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_current', 'unit_of_measurement': , }) # --- @@ -1115,7 +1115,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.051724052462ab286504cur_power', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_power', 'unit_of_measurement': 'W', }) # --- @@ -1174,7 +1174,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', - 'unique_id': 'tuya.051724052462ab286504cur_voltage', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_voltage', 'unit_of_measurement': , }) # --- @@ -1233,7 +1233,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current', - 'unique_id': 'tuya.mocked_device_idcur_current', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_current', 'unit_of_measurement': , }) # --- @@ -1289,7 +1289,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.mocked_device_idcur_power', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_power', 'unit_of_measurement': 'W', }) # --- @@ -1348,7 +1348,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', - 'unique_id': 'tuya.mocked_device_idcur_voltage', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_voltage', 'unit_of_measurement': , }) # --- @@ -1404,7 +1404,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_current', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_aelectriccurrent', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_aelectriccurrent', 'unit_of_measurement': , }) # --- @@ -1460,7 +1460,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_power', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_apower', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_apower', 'unit_of_measurement': , }) # --- @@ -1516,7 +1516,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_voltage', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_avoltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_avoltage', 'unit_of_measurement': , }) # --- @@ -1572,7 +1572,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_b_current', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_belectriccurrent', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_belectriccurrent', 'unit_of_measurement': , }) # --- @@ -1628,7 +1628,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_b_power', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bpower', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bpower', 'unit_of_measurement': , }) # --- @@ -1684,7 +1684,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_b_voltage', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bvoltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bvoltage', 'unit_of_measurement': , }) # --- @@ -1740,7 +1740,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_c_current', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_celectriccurrent', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_celectriccurrent', 'unit_of_measurement': , }) # --- @@ -1796,7 +1796,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_c_power', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cpower', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cpower', 'unit_of_measurement': , }) # --- @@ -1852,7 +1852,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_c_voltage', - 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cvoltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cvoltage', 'unit_of_measurement': , }) # --- @@ -1905,7 +1905,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bfbc8a692eaeeef455fkctbattery_percentage', + 'unique_id': 'tuya.ohefbbk9gcdlbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -1958,7 +1958,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'illuminance', - 'unique_id': 'tuya.bfbc8a692eaeeef455fkctbright_value', + 'unique_id': 'tuya.ohefbbk9gcdlbright_value', 'unit_of_measurement': 'lx', }) # --- @@ -2011,7 +2011,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf5cccf9027080e2dbb9w3battery', + 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmbattery', 'unit_of_measurement': '%', }) # --- @@ -2067,7 +2067,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current_temperature', - 'unique_id': 'tuya.bff434eca843ffc9afmthvtemp_current', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmtemp_current', 'unit_of_measurement': , }) # --- @@ -2118,7 +2118,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'remaining_time', - 'unique_id': 'tuya.bff434eca843ffc9afmthvremain_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmremain_time', 'unit_of_measurement': , }) # --- @@ -2167,7 +2167,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sous_vide_status', - 'unique_id': 'tuya.bff434eca843ffc9afmthvstatus', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstatus', 'unit_of_measurement': None, }) # --- @@ -2215,7 +2215,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.73486068483fda10d633battery_state', + 'unique_id': 'tuya.hkm4px9ohzozxma3ripbattery_state', 'unit_of_measurement': None, }) # --- @@ -2265,7 +2265,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf445324326cbde7c5rg7bbattery_percentage', + 'unique_id': 'tuya.s3zzjdcfripbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -2316,7 +2316,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.20401777500291cfe3a2battery_state', + 'unique_id': 'tuya.zoytcemodrn39zqwripbattery_state', 'unit_of_measurement': None, }) # --- @@ -2369,7 +2369,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'air_pressure', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzatmospheric_pressture', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqatmospheric_pressture', 'unit_of_measurement': 'hPa', }) # --- @@ -2420,7 +2420,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbattery_state', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqbattery_state', 'unit_of_measurement': None, }) # --- @@ -2470,7 +2470,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_value', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_value', 'unit_of_measurement': '%', }) # --- @@ -2523,7 +2523,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'illuminance', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbright_value', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqbright_value', 'unit_of_measurement': 'lx', }) # --- @@ -2576,7 +2576,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity_outdoor', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor', 'unit_of_measurement': '%', }) # --- @@ -2629,7 +2629,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_humidity_outdoor', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_1', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_1', 'unit_of_measurement': '%', }) # --- @@ -2682,7 +2682,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_humidity_outdoor', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_2', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_2', 'unit_of_measurement': '%', }) # --- @@ -2735,7 +2735,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_humidity_outdoor', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_outdoor_3', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqhumidity_outdoor_3', 'unit_of_measurement': '%', }) # --- @@ -2791,7 +2791,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_external', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external', 'unit_of_measurement': , }) # --- @@ -2847,7 +2847,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_temperature_external', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_1', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_1', 'unit_of_measurement': , }) # --- @@ -2903,7 +2903,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_temperature_external', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_2', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_2', 'unit_of_measurement': , }) # --- @@ -2959,7 +2959,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_temperature_external', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external_3', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current_external_3', 'unit_of_measurement': , }) # --- @@ -3015,7 +3015,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqtemp_current', 'unit_of_measurement': , }) # --- @@ -3074,7 +3074,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wind_speed', - 'unique_id': 'tuya.bf84c743a84eb2c8abeurzwindspeed_avg', + 'unique_id': 'tuya.6tbtkuv3tal1aesfjxqwindspeed_avg', 'unit_of_measurement': , }) # --- @@ -3125,7 +3125,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unique_id': 'tuya.ase6htln9tdni2sijxqbattery_state', 'unit_of_measurement': None, }) # --- @@ -3175,7 +3175,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unique_id': 'tuya.ase6htln9tdni2sijxqhumidity_value', 'unit_of_measurement': '%', }) # --- @@ -3231,7 +3231,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature_external', - 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unique_id': 'tuya.ase6htln9tdni2sijxqtemp_current_external', 'unit_of_measurement': , }) # --- @@ -3287,7 +3287,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unique_id': 'tuya.ase6htln9tdni2sijxqtemp_current', 'unit_of_measurement': , }) # --- @@ -3340,7 +3340,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'gas', - 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_value', + 'unique_id': 'tuya.cwwk68dyfsh2eqi4jbqrgas_sensor_value', 'unit_of_measurement': 'ppm', }) # --- @@ -3392,7 +3392,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtelectricity_left', + 'unique_id': 'tuya.zrrraytdoanz33rldselectricity_left', 'unit_of_measurement': '%', }) # --- @@ -3445,7 +3445,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cleaning_area', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_area', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_area', 'unit_of_measurement': '㎡', }) # --- @@ -3497,7 +3497,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'cleaning_time', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtclean_time', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_time', 'unit_of_measurement': 'min', }) # --- @@ -3549,7 +3549,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'duster_cloth_life', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtduster_cloth', + 'unique_id': 'tuya.zrrraytdoanz33rldsduster_cloth', 'unit_of_measurement': 'min', }) # --- @@ -3601,7 +3601,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_life', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtfilter', + 'unique_id': 'tuya.zrrraytdoanz33rldsfilter', 'unit_of_measurement': 'min', }) # --- @@ -3653,7 +3653,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'rolling_brush_life', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtroll_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsroll_brush', 'unit_of_measurement': 'min', }) # --- @@ -3705,7 +3705,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'side_brush_life', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtedge_brush', + 'unique_id': 'tuya.zrrraytdoanz33rldsedge_brush', 'unit_of_measurement': 'min', }) # --- @@ -3757,7 +3757,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_cleaning_area', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_area', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_area', 'unit_of_measurement': '㎡', }) # --- @@ -3809,7 +3809,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_cleaning_time', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_time', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_time', 'unit_of_measurement': 'min', }) # --- @@ -3861,7 +3861,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_cleaning_times', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmttotal_clean_count', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_count', 'unit_of_measurement': None, }) # --- @@ -3912,7 +3912,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf58e095fd2d86d592tvehbattery_percentage', + 'unique_id': 'tuya.codvtvgtjsbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -3965,7 +3965,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfwireless_electricity', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspswireless_electricity', 'unit_of_measurement': '%', }) # --- @@ -4024,7 +4024,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'current', - 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_current', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_current', 'unit_of_measurement': , }) # --- @@ -4080,7 +4080,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_power', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_power', 'unit_of_measurement': 'W', }) # --- @@ -4139,7 +4139,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'voltage', - 'unique_id': 'tuya.bf0dc19ab84dc3627ep2uncur_voltage', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtcur_voltage', 'unit_of_measurement': , }) # --- @@ -4192,7 +4192,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bfdb773e4ae317e3915h2ibattery_percentage', + 'unique_id': 'tuya.couukaypjdnytbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -4243,7 +4243,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.bfdb773e4ae317e3915h2ibattery_state', + 'unique_id': 'tuya.couukaypjdnytbattery_state', 'unit_of_measurement': None, }) # --- @@ -4293,7 +4293,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bfb45cb8a9452fba66lexgbattery_percentage', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -4346,7 +4346,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf316b8707b061f044th18battery_percentage', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -4399,7 +4399,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.bf316b8707b061f044th18va_humidity', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_humidity', 'unit_of_measurement': '%', }) # --- @@ -4455,7 +4455,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': 'tuya.bf316b8707b061f044th18va_temperature', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_temperature', 'unit_of_measurement': , }) # --- @@ -4508,7 +4508,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.mocked_device_idbattery_percentage', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -4561,7 +4561,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.8670375210521cf1349cbattery_percentage', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -4612,7 +4612,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.8670375210521cf1349cbattery_state', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_state', 'unit_of_measurement': None, }) # --- @@ -4665,7 +4665,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'depth', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth', 'unit_of_measurement': 'm', }) # --- @@ -4718,7 +4718,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'liquid_level', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_level_percent', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_level_percent', 'unit_of_measurement': '%', }) # --- @@ -4768,7 +4768,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'liquid_state', - 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_state', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_state', 'unit_of_measurement': None, }) # --- @@ -4821,7 +4821,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'depth', - 'unique_id': 'tuya.mocked_device_idliquid_depth', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth', 'unit_of_measurement': 'm', }) # --- @@ -4874,7 +4874,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'liquid_level', - 'unique_id': 'tuya.mocked_device_idliquid_level_percent', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_level_percent', 'unit_of_measurement': '%', }) # --- @@ -4924,7 +4924,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'liquid_state', - 'unique_id': 'tuya.mocked_device_idliquid_state', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_state', 'unit_of_measurement': None, }) # --- @@ -4977,7 +4977,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_current', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_aelectriccurrent', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_aelectriccurrent', 'unit_of_measurement': , }) # --- @@ -5033,7 +5033,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_power', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_apower', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_apower', 'unit_of_measurement': , }) # --- @@ -5089,7 +5089,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_voltage', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dphase_avoltage', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzphase_avoltage', 'unit_of_measurement': , }) # --- @@ -5145,7 +5145,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_energy', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dforward_energy_total', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzforward_energy_total', 'unit_of_measurement': , }) # --- @@ -5201,7 +5201,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'total_production', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dreverse_energy_total', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzreverse_energy_total', 'unit_of_measurement': , }) # --- @@ -5257,7 +5257,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_current', - 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_aelectriccurrent', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_aelectriccurrent', 'unit_of_measurement': , }) # --- @@ -5313,7 +5313,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_power', - 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_apower', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_apower', 'unit_of_measurement': , }) # --- @@ -5369,7 +5369,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'phase_a_voltage', - 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_avoltage', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_avoltage', 'unit_of_measurement': , }) # --- @@ -5422,7 +5422,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_percentage', + 'unique_id': 'tuya.uew54dymycjwzbattery_percentage', 'unit_of_measurement': '%', }) # --- @@ -5473,7 +5473,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery_state', - 'unique_id': 'tuya.bf1a0431555359ce06ie0zbattery_state', + 'unique_id': 'tuya.uew54dymycjwzbattery_state', 'unit_of_measurement': None, }) # --- @@ -5523,7 +5523,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'humidity', - 'unique_id': 'tuya.bf1a0431555359ce06ie0zhumidity', + 'unique_id': 'tuya.uew54dymycjwzhumidity', 'unit_of_measurement': '%', }) # --- @@ -5579,7 +5579,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'temperature', - 'unique_id': 'tuya.bf1a0431555359ce06ie0ztemp_current', + 'unique_id': 'tuya.uew54dymycjwztemp_current', 'unit_of_measurement': , }) # --- diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 876db171c7b..7748d1648d8 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_switch', + 'unique_id': 'tuya.iks13mcaiyie3rryjb2ocalarm_switch', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf0984adfeffe10d5a3ofdalarm_switch', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_switch', 'unit_of_measurement': None, }) # --- @@ -128,7 +128,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfsiren_switch', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspssiren_switch', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1b8cd9b807c..9e1e88babac 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -30,7 +30,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reverse', - 'unique_id': 'tuya.mocked_device_idcontrol_back', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol_back', 'unit_of_measurement': None, }) # --- @@ -78,7 +78,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.mock_device_idchild_lock', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscchild_lock', 'unit_of_measurement': None, }) # --- @@ -127,7 +127,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ionizer', - 'unique_id': 'tuya.mock_device_idanion', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscanion', 'unit_of_measurement': None, }) # --- @@ -176,7 +176,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf3fce6af592f12df3gbgqchild_lock', + 'unique_id': 'tuya.2myxayqtud9aqbizscchild_lock', 'unit_of_measurement': None, }) # --- @@ -225,7 +225,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch', - 'unique_id': 'tuya.bf6574iutyikgwkxswitch', + 'unique_id': 'tuya.rl39uwgaqwjwcswitch', 'unit_of_measurement': None, }) # --- @@ -273,7 +273,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'filter_reset', - 'unique_id': 'tuya.23536058083a8dc57d96filter_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_reset', 'unit_of_measurement': None, }) # --- @@ -321,7 +321,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.23536058083a8dc57d96switch', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcswitch', 'unit_of_measurement': None, }) # --- @@ -369,7 +369,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'reset_of_water_usage_days', - 'unique_id': 'tuya.23536058083a8dc57d96water_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_reset', 'unit_of_measurement': None, }) # --- @@ -417,7 +417,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_sterilization', - 'unique_id': 'tuya.23536058083a8dc57d96uv', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv', 'unit_of_measurement': None, }) # --- @@ -465,7 +465,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'water_pump_reset', - 'unique_id': 'tuya.23536058083a8dc57d96pump_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_reset', 'unit_of_measurement': None, }) # --- @@ -513,7 +513,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.01155072c4dd573f92b8switch_1', + 'unique_id': 'tuya.ncl7oi5d6hqmf1g0zcswitch_1', 'unit_of_measurement': None, }) # --- @@ -562,7 +562,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_1', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_1', 'unit_of_measurement': None, }) # --- @@ -611,7 +611,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_2', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_2', 'unit_of_measurement': None, }) # --- @@ -660,7 +660,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.53703774d8f15ba9efd3switch_1', + 'unique_id': 'tuya.2k8wyjo7iidkohuczcswitch_1', 'unit_of_measurement': None, }) # --- @@ -709,7 +709,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.bf7a2cdaf3ce28d2f7uqnhswitch_1', + 'unique_id': 'tuya.zspxfhsvgn2hgtndzcswitch_1', 'unit_of_measurement': None, }) # --- @@ -758,7 +758,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.051724052462ab286504switch_1', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcswitch_1', 'unit_of_measurement': None, }) # --- @@ -807,7 +807,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2echild_lock', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcchild_lock', 'unit_of_measurement': None, }) # --- @@ -855,7 +855,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.bf4c0c538bfe408aa9gr2eswitch_1', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcswitch_1', 'unit_of_measurement': None, }) # --- @@ -904,7 +904,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.mocked_device_idchild_lock', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldchild_lock', 'unit_of_measurement': None, }) # --- @@ -952,7 +952,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch', - 'unique_id': 'tuya.mocked_device_idswitch', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldswitch', 'unit_of_measurement': None, }) # --- @@ -1000,7 +1000,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.0665305284f3ebe9fdc1switch_1', + 'unique_id': 'tuya.a4zeazrz1ata9mbggkswitch_1', 'unit_of_measurement': None, }) # --- @@ -1049,7 +1049,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.152027113c6105cce49clock', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjklock', 'unit_of_measurement': None, }) # --- @@ -1097,7 +1097,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ionizer', - 'unique_id': 'tuya.152027113c6105cce49canion', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkanion', 'unit_of_measurement': None, }) # --- @@ -1145,7 +1145,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.152027113c6105cce49cswitch', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkswitch', 'unit_of_measurement': None, }) # --- @@ -1193,7 +1193,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'uv_sterilization', - 'unique_id': 'tuya.152027113c6105cce49cuv', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkuv', 'unit_of_measurement': None, }) # --- @@ -1241,7 +1241,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power', - 'unique_id': 'tuya.CENSOREDswitch', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkswitch', 'unit_of_measurement': None, }) # --- @@ -1289,7 +1289,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'ionizer', - 'unique_id': 'tuya.mock_device_idanion', + 'unique_id': 'tuya.lflvu8cazha8af9jskanion', 'unit_of_measurement': None, }) # --- @@ -1337,7 +1337,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'arm_beep', - 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_sound', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_sound', 'unit_of_measurement': None, }) # --- @@ -1385,7 +1385,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'siren', - 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_light', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_light', 'unit_of_measurement': None, }) # --- @@ -1433,7 +1433,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'start', - 'unique_id': 'tuya.bff434eca843ffc9afmthvstart', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstart', 'unit_of_measurement': None, }) # --- @@ -1481,7 +1481,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.bf2206da15147500969d6eswitch_1', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_1', 'unit_of_measurement': None, }) # --- @@ -1530,7 +1530,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.bf2206da15147500969d6eswitch_2', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_2', 'unit_of_measurement': None, }) # --- @@ -1579,7 +1579,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.15727703c4dd5709cd78switch_1', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_1', 'unit_of_measurement': None, }) # --- @@ -1628,7 +1628,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.15727703c4dd5709cd78switch_2', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_2', 'unit_of_measurement': None, }) # --- @@ -1677,7 +1677,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch', - 'unique_id': 'tuya.bf83514d9c14b426f0fz5yswitch', + 'unique_id': 'tuya.qyy1auihjyoogvb7zdccqswitch', 'unit_of_measurement': None, }) # --- @@ -1725,7 +1725,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'do_not_disturb', - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmtswitch_disturb', + 'unique_id': 'tuya.zrrraytdoanz33rldsswitch_disturb', 'unit_of_measurement': None, }) # --- @@ -1773,7 +1773,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch', - 'unique_id': 'tuya.bfb9bfc18eeaed2d85yt5mswitch', + 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', 'unit_of_measurement': None, }) # --- @@ -1821,7 +1821,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flip', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_flip', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_flip', 'unit_of_measurement': None, }) # --- @@ -1869,7 +1869,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmmotion_switch', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_switch', 'unit_of_measurement': None, }) # --- @@ -1917,7 +1917,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_detection', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmdecibel_switch', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_switch', 'unit_of_measurement': None, }) # --- @@ -1965,7 +1965,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time_watermark', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmbasic_osd', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_osd', 'unit_of_measurement': None, }) # --- @@ -2013,7 +2013,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'video_recording', - 'unique_id': 'tuya.bf7b8e59f8cd49f425mmfmrecord_switch', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_switch', 'unit_of_measurement': None, }) # --- @@ -2061,7 +2061,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flip', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_flip', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_flip', 'unit_of_measurement': None, }) # --- @@ -2109,7 +2109,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9motion_switch', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_switch', 'unit_of_measurement': None, }) # --- @@ -2157,7 +2157,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'sound_detection', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9decibel_switch', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_switch', 'unit_of_measurement': None, }) # --- @@ -2205,7 +2205,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time_watermark', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9basic_osd', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_osd', 'unit_of_measurement': None, }) # --- @@ -2253,7 +2253,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'video_recording', - 'unique_id': 'tuya.bf9d5b7ea61ea4c9a6rom9record_switch', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_switch', 'unit_of_measurement': None, }) # --- @@ -2301,7 +2301,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'flip', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_flip', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_flip', 'unit_of_measurement': None, }) # --- @@ -2349,7 +2349,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_switch', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_switch', 'unit_of_measurement': None, }) # --- @@ -2397,7 +2397,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_recording', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_record', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_record', 'unit_of_measurement': None, }) # --- @@ -2445,7 +2445,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'motion_tracking', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfmotion_tracking', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_tracking', 'unit_of_measurement': None, }) # --- @@ -2493,7 +2493,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'time_watermark', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_osd', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_osd', 'unit_of_measurement': None, }) # --- @@ -2541,7 +2541,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'video_recording', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfrecord_switch', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_switch', 'unit_of_measurement': None, }) # --- @@ -2589,7 +2589,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'wide_dynamic_range', - 'unique_id': 'tuya.bf3f8b448bbc123e29oghfbasic_wdr', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_wdr', 'unit_of_measurement': None, }) # --- @@ -2637,7 +2637,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bfa008a4f82a56616c69uzswitch_1', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtswitch_1', 'unit_of_measurement': None, }) # --- @@ -2686,7 +2686,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bff35871a2f4430058vs8uswitch_1', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtswitch_1', 'unit_of_measurement': None, }) # --- @@ -2735,7 +2735,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_1', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_1', 'unit_of_measurement': None, }) # --- @@ -2784,7 +2784,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_2', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_2', 'unit_of_measurement': None, }) # --- @@ -2833,7 +2833,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_3', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_3', 'unit_of_measurement': None, }) # --- @@ -2882,7 +2882,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_4', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtswitch_4', 'unit_of_measurement': None, }) # --- @@ -2931,7 +2931,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.d7ca553b5f406266350pocchild_lock', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtchild_lock', 'unit_of_measurement': None, }) # --- @@ -2979,7 +2979,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_1', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_1', 'unit_of_measurement': None, }) # --- @@ -3028,7 +3028,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_2', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_2', 'unit_of_measurement': None, }) # --- @@ -3077,7 +3077,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_3', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_3', 'unit_of_measurement': None, }) # --- @@ -3126,7 +3126,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_4', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_4', 'unit_of_measurement': None, }) # --- @@ -3175,7 +3175,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_5', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_5', 'unit_of_measurement': None, }) # --- @@ -3224,7 +3224,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.d7ca553b5f406266350pocswitch_6', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_6', 'unit_of_measurement': None, }) # --- @@ -3273,7 +3273,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.bf0dc19ab84dc3627ep2unswitch_1', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtswitch_1', 'unit_of_measurement': None, }) # --- @@ -3322,7 +3322,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saving', - 'unique_id': 'tuya.bfdb773e4ae317e3915h2iswitch_save_energy', + 'unique_id': 'tuya.couukaypjdnytswitch_save_energy', 'unit_of_measurement': None, }) # --- @@ -3370,7 +3370,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf3c2c83660b8e19e152jbchild_lock', + 'unique_id': 'tuya.dn7cjik6kwchild_lock', 'unit_of_measurement': None, }) # --- @@ -3418,7 +3418,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf6fc1645146455a2efrexchild_lock', + 'unique_id': 'tuya.x7quooqakwchild_lock', 'unit_of_measurement': None, }) # --- @@ -3466,7 +3466,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bfb45cb8a9452fba66lexgchild_lock', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwchild_lock', 'unit_of_measurement': None, }) # --- @@ -3514,7 +3514,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf1085bf049a74fcc1idy2child_lock', + 'unique_id': 'tuya.sb3zdertrw50bgogkwchild_lock', 'unit_of_measurement': None, }) # --- @@ -3562,7 +3562,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.bf8d64588f4a61965ezszschild_lock', + 'unique_id': 'tuya.jm2fsqtzuhqtbo5ykwchild_lock', 'unit_of_measurement': None, }) # --- @@ -3610,7 +3610,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'switch', - 'unique_id': 'tuya.6c0887b46a2eaf56e0ui7dswitch', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzswitch', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr index bc9ecd197d4..e75e33af002 100644 --- a/tests/components/tuya/snapshots/test_vacuum.ambr +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -36,7 +36,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.bfa951ca98fcf64fddqlmt', + 'unique_id': 'tuya.zrrraytdoanz33rlds', 'unit_of_measurement': None, }) # --- From 6a81bf6f5eb03370c7a22c52c73c9ee2b5e9bdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Fri, 8 Aug 2025 11:40:04 +0200 Subject: [PATCH 1010/1118] Improve interface between Miele integration and pymiele library (#150214) --- homeassistant/components/miele/__init__.py | 4 +++- homeassistant/components/miele/coordinator.py | 5 ++--- homeassistant/components/miele/entity.py | 5 ++--- tests/components/miele/conftest.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py index 2c5c250aee7..173865195df 100644 --- a/homeassistant/components/miele/__init__.py +++ b/homeassistant/components/miele/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiohttp import ClientError, ClientResponseError +from pymiele import MieleAPI from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -66,7 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> boo ) from err # Setup MieleAPI and coordinator for data fetch - coordinator = MieleDataUpdateCoordinator(hass, entry, auth) + api = MieleAPI(auth) + coordinator = MieleDataUpdateCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py index d5de2d79cb9..98f5c9f8b1c 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -8,13 +8,12 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pymiele import MieleAction, MieleDevice +from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .api import AsyncConfigEntryAuth from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,7 +42,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]): self, hass: HomeAssistant, config_entry: MieleConfigEntry, - api: AsyncConfigEntryAuth, + api: MieleAPI, ) -> None: """Initialize the Miele data coordinator.""" super().__init__( diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index 4c6e61f6ea5..57c10f6f7bd 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -1,12 +1,11 @@ """Entity base class for the Miele integration.""" -from pymiele import MieleAction, MieleDevice +from pymiele import MieleAction, MieleAPI, MieleDevice from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import AsyncConfigEntryAuth from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus from .coordinator import MieleDataUpdateCoordinator @@ -57,7 +56,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): return self.coordinator.data.actions[self._device_id] @property - def api(self) -> AsyncConfigEntryAuth: + def api(self) -> MieleAPI: """Return the api object.""" return self.coordinator.api diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index d91485ffc59..c8a47eb2b59 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -117,7 +117,7 @@ def mock_miele_client( """Mock a Miele client.""" with patch( - "homeassistant.components.miele.AsyncConfigEntryAuth", + "homeassistant.components.miele.MieleAPI", autospec=True, ) as mock_client: client = mock_client.return_value From 5b046def8e6c332f3e21494acd65bb6f56e4eb3b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Aug 2025 12:02:07 +0200 Subject: [PATCH 1011/1118] Move holiday object to runtime data in workday (#149122) --- homeassistant/components/workday/__init__.py | 125 ++++----- .../components/workday/binary_sensor.py | 215 +-------------- homeassistant/components/workday/util.py | 254 ++++++++++++++++++ 3 files changed, 308 insertions(+), 286 deletions(-) create mode 100644 homeassistant/components/workday/util.py diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 0df4224a4ca..cbcf12cf31c 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -2,103 +2,72 @@ from __future__ import annotations -from functools import partial +from datetime import timedelta +from typing import cast -from holidays import HolidayBase, country_holidays +from holidays import DateLike, HolidayBase from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.util import dt as dt_util -from .const import CONF_PROVINCE, DOMAIN, PLATFORMS +from .const import ( + CONF_ADD_HOLIDAYS, + CONF_CATEGORY, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + LOGGER, + PLATFORMS, +) +from .util import ( + add_remove_custom_holidays, + async_validate_country_and_province, + get_holidays_object, + validate_dates, +) + +type WorkdayConfigEntry = ConfigEntry[HolidayBase] -async def _async_validate_country_and_province( - hass: HomeAssistant, entry: ConfigEntry, country: str | None, province: str | None -) -> None: - """Validate country and province.""" - - if not country: - return - try: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - await hass.async_add_import_executor_job(country_holidays, country) - except NotImplementedError as ex: - async_create_issue( - hass, - DOMAIN, - "bad_country", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.ERROR, - translation_key="bad_country", - translation_placeholders={"title": entry.title}, - data={"entry_id": entry.entry_id, "country": None}, - ) - raise ConfigEntryError(f"Selected country {country} is not valid") from ex - - if not province: - return - try: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - await hass.async_add_import_executor_job( - partial(country_holidays, country, subdiv=province) - ) - except NotImplementedError as ex: - async_create_issue( - hass, - DOMAIN, - "bad_province", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.ERROR, - translation_key="bad_province", - translation_placeholders={ - CONF_COUNTRY: country, - "title": entry.title, - }, - data={"entry_id": entry.entry_id, "country": country}, - ) - raise ConfigEntryError( - f"Selected province {province} for country {country} is not valid" - ) from ex - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Set up Workday from a config entry.""" + calc_add_holidays = cast( + list[DateLike], validate_dates(entry.options[CONF_ADD_HOLIDAYS]) + ) + calc_remove_holidays: list[str] = validate_dates( + entry.options[CONF_REMOVE_HOLIDAYS] + ) + categories: list[str] | None = entry.options.get(CONF_CATEGORY) country: str | None = entry.options.get(CONF_COUNTRY) + days_offset: int = int(entry.options[CONF_OFFSET]) + language: str | None = entry.options.get(CONF_LANGUAGE) province: str | None = entry.options.get(CONF_PROVINCE) + year: int = (dt_util.now() + timedelta(days=days_offset)).year - await _async_validate_country_and_province(hass, entry, country, province) + await async_validate_country_and_province(hass, entry, country, province) - if country and CONF_LANGUAGE not in entry.options: - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # import executor job is used here because multiple integrations use - # the holidays library and it is not thread safe to import it in parallel - # https://github.com/python/cpython/issues/83065 - cls: HolidayBase = await hass.async_add_import_executor_job( - partial(country_holidays, country, subdiv=province) - ) - default_language = cls.default_language - new_options = entry.options.copy() - new_options[CONF_LANGUAGE] = default_language - hass.config_entries.async_update_entry(entry, options=new_options) + entry.runtime_data = await hass.async_add_executor_job( + get_holidays_object, country, province, year, language, categories + ) + + add_remove_custom_holidays( + hass, entry, country, calc_add_holidays, calc_remove_holidays + ) + + LOGGER.debug("Found the following holidays for your configuration:") + for holiday_date, name in sorted(entry.runtime_data.items()): + # Make explicit str variable to avoid "Incompatible types in assignment" + _holiday_string = holiday_date.strftime("%Y-%m-%d") + LOGGER.debug("%s %s", _holiday_string, name) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WorkdayConfigEntry) -> bool: """Unload Workday config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index a48e19e59b2..dcda7b901a1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -5,17 +5,11 @@ from __future__ import annotations from datetime import date, datetime, timedelta from typing import Final -from holidays import ( - PUBLIC, - HolidayBase, - __version__ as python_holidays_version, - country_holidays, -) +from holidays import HolidayBase, __version__ as python_holidays_version import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_NAME from homeassistant.core import ( CALLBACK_TYPE, HomeAssistant, @@ -30,221 +24,26 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util -from .const import ( - ALLOWED_DAYS, - CONF_ADD_HOLIDAYS, - CONF_CATEGORY, - CONF_EXCLUDES, - CONF_OFFSET, - CONF_PROVINCE, - CONF_REMOVE_HOLIDAYS, - CONF_WORKDAYS, - DOMAIN, - LOGGER, -) +from . import WorkdayConfigEntry +from .const import ALLOWED_DAYS, CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS, DOMAIN SERVICE_CHECK_DATE: Final = "check_date" CHECK_DATE: Final = "check_date" -def validate_dates(holiday_list: list[str]) -> list[str]: - """Validate and adds to list of dates to add or remove.""" - calc_holidays: list[str] = [] - for add_date in holiday_list: - if add_date.find(",") > 0: - dates = add_date.split(",", maxsplit=1) - d1 = dt_util.parse_date(dates[0]) - d2 = dt_util.parse_date(dates[1]) - if d1 is None or d2 is None: - LOGGER.error("Incorrect dates in date range: %s", add_date) - continue - _range: timedelta = d2 - d1 - for i in range(_range.days + 1): - day: date = d1 + timedelta(days=i) - calc_holidays.append(day.strftime("%Y-%m-%d")) - continue - calc_holidays.append(add_date) - return calc_holidays - - -def _get_obj_holidays( - country: str | None, - province: str | None, - year: int, - language: str | None, - categories: list[str] | None, -) -> HolidayBase: - """Get the object for the requested country and year.""" - if not country: - return HolidayBase() - - set_categories = None - if categories: - category_list = [PUBLIC] - category_list.extend(categories) - set_categories = tuple(category_list) - - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=[year, year + 1], - language=language, - categories=set_categories, - ) - - supported_languages = obj_holidays.supported_languages - default_language = obj_holidays.default_language - - if default_language and not language: - # If no language is set, use the default language - LOGGER.debug("Changing language from None to %s", default_language) - return country_holidays( # Return default if no language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - - if ( - default_language - and language - and language not in supported_languages - and language.startswith("en") - ): - # If language does not match supported languages, use the first English variant - if default_language.startswith("en"): - LOGGER.debug("Changing language from %s to %s", language, default_language) - return country_holidays( # Return default English if default language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - for lang in supported_languages: - if lang.startswith("en"): - LOGGER.debug("Changing language from %s to %s", language, lang) - return country_holidays( - country, - subdiv=province, - years=year, - language=lang, - categories=set_categories, - ) - - if default_language and language and language not in supported_languages: - # If language does not match supported languages, use the default language - LOGGER.debug("Changing language from %s to %s", language, default_language) - return country_holidays( # Return default English if default language - country, - subdiv=province, - years=year, - language=default_language, - categories=set_categories, - ) - - return obj_holidays - - async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WorkdayConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Workday sensor.""" - add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] - remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] - country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] - province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - language: str | None = entry.options.get(CONF_LANGUAGE) - categories: list[str] | None = entry.options.get(CONF_CATEGORY) - - year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = await hass.async_add_executor_job( - _get_obj_holidays, country, province, year, language, categories - ) - calc_add_holidays: list[str] = validate_dates(add_holidays) - calc_remove_holidays: list[str] = validate_dates(remove_holidays) - next_year = dt_util.now().year + 1 - - # Add custom holidays - try: - obj_holidays.append(calc_add_holidays) # type: ignore[arg-type] - except ValueError as error: - LOGGER.error("Could not add custom holidays: %s", error) - - # Remove holidays - for remove_holiday in calc_remove_holidays: - try: - # is this formatted as a date? - if dt_util.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) - except KeyError as unmatched: - LOGGER.warning("No holiday found matching %s", unmatched) - if _date := dt_util.parse_date(remove_holiday): - if _date.year <= next_year: - # Only check and raise issues for current and next year - async_create_issue( - hass, - DOMAIN, - f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_date_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_named_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) - - LOGGER.debug("Found the following holidays for your configuration:") - for holiday_date, name in sorted(obj_holidays.items()): - # Make explicit str variable to avoid "Incompatible types in assignment" - _holiday_string = holiday_date.strftime("%Y-%m-%d") - LOGGER.debug("%s %s", _holiday_string, name) + obj_holidays = entry.runtime_data platform = async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/workday/util.py b/homeassistant/components/workday/util.py new file mode 100644 index 00000000000..726563febaf --- /dev/null +++ b/homeassistant/components/workday/util.py @@ -0,0 +1,254 @@ +"""Helpers functions for the Workday component.""" + +from datetime import date, timedelta +from functools import partial +from typing import TYPE_CHECKING + +from holidays import PUBLIC, DateLike, HolidayBase, country_holidays + +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.util import dt as dt_util, slugify + +if TYPE_CHECKING: + from . import WorkdayConfigEntry +from .const import CONF_REMOVE_HOLIDAYS, DOMAIN, LOGGER + + +async def async_validate_country_and_province( + hass: HomeAssistant, + entry: "WorkdayConfigEntry", + country: str | None, + province: str | None, +) -> None: + """Validate country and province.""" + + if not country: + return + try: + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job(country_holidays, country) + except NotImplementedError as ex: + async_create_issue( + hass, + DOMAIN, + "bad_country", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.ERROR, + translation_key="bad_country", + translation_placeholders={"title": entry.title}, + data={"entry_id": entry.entry_id, "country": None}, + ) + raise ConfigEntryError(f"Selected country {country} is not valid") from ex + + if not province: + return + try: + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # import executor job is used here because multiple integrations use + # the holidays library and it is not thread safe to import it in parallel + # https://github.com/python/cpython/issues/83065 + await hass.async_add_import_executor_job( + partial(country_holidays, country, subdiv=province) + ) + except NotImplementedError as ex: + async_create_issue( + hass, + DOMAIN, + "bad_province", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.ERROR, + translation_key="bad_province", + translation_placeholders={ + CONF_COUNTRY: country, + "title": entry.title, + }, + data={"entry_id": entry.entry_id, "country": country}, + ) + raise ConfigEntryError( + f"Selected province {province} for country {country} is not valid" + ) from ex + + +def validate_dates(holiday_list: list[str]) -> list[str]: + """Validate and add to list of dates to add or remove.""" + calc_holidays: list[str] = [] + for add_date in holiday_list: + if add_date.find(",") > 0: + dates = add_date.split(",", maxsplit=1) + d1 = dt_util.parse_date(dates[0]) + d2 = dt_util.parse_date(dates[1]) + if d1 is None or d2 is None: + LOGGER.error("Incorrect dates in date range: %s", add_date) + continue + _range: timedelta = d2 - d1 + for i in range(_range.days + 1): + day: date = d1 + timedelta(days=i) + calc_holidays.append(day.strftime("%Y-%m-%d")) + continue + calc_holidays.append(add_date) + return calc_holidays + + +def get_holidays_object( + country: str | None, + province: str | None, + year: int, + language: str | None, + categories: list[str] | None, +) -> HolidayBase: + """Get the object for the requested country and year.""" + if not country: + return HolidayBase() + + set_categories = None + if categories: + category_list = [PUBLIC] + category_list.extend(categories) + set_categories = tuple(category_list) + + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=[year, year + 1], + language=language, + categories=set_categories, + ) + + supported_languages = obj_holidays.supported_languages + default_language = obj_holidays.default_language + + if default_language and not language: + # If no language is set, use the default language + LOGGER.debug("Changing language from None to %s", default_language) + return country_holidays( # Return default if no language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + + if ( + default_language + and language + and language not in supported_languages + and language.startswith("en") + ): + # If language does not match supported languages, use the first English variant + if default_language.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + for lang in supported_languages: + if lang.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, lang) + return country_holidays( + country, + subdiv=province, + years=year, + language=lang, + categories=set_categories, + ) + + if default_language and language and language not in supported_languages: + # If language does not match supported languages, use the default language + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + + return obj_holidays + + +def add_remove_custom_holidays( + hass: HomeAssistant, + entry: "WorkdayConfigEntry", + country: str | None, + calc_add_holidays: list[DateLike], + calc_remove_holidays: list[str], +) -> None: + """Add or remove custom holidays.""" + next_year = dt_util.now().year + 1 + + # Add custom holidays + try: + entry.runtime_data.append(calc_add_holidays) + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) + + # Remove custom holidays + for remove_holiday in calc_remove_holidays: + try: + # is this formatted as a date? + if dt_util.parse_date(remove_holiday): + # remove holiday by date + removed = entry.runtime_data.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = entry.runtime_data.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) + if _date := dt_util.parse_date(remove_holiday): + if _date.year <= next_year: + # Only check and raise issues for max next year + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) From 8e12d2028d7b731f5510b46ec5f8969585a532cc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Aug 2025 13:09:01 +0200 Subject: [PATCH 1012/1118] Remove previously deprecated linear_garage_door (#150109) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .strict-typing | 1 - CODEOWNERS | 2 - .../components/linear_garage_door/__init__.py | 54 ---- .../linear_garage_door/config_flow.py | 160 ------------ .../components/linear_garage_door/const.py | 3 - .../linear_garage_door/coordinator.py | 86 ------- .../components/linear_garage_door/cover.py | 85 ------- .../linear_garage_door/diagnostics.py | 29 --- .../components/linear_garage_door/entity.py | 43 ---- .../components/linear_garage_door/light.py | 78 ------ .../linear_garage_door/manifest.json | 9 - .../linear_garage_door/strings.json | 33 --- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - mypy.ini | 10 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - .../components/linear_garage_door/__init__.py | 22 -- .../components/linear_garage_door/conftest.py | 67 ----- .../fixtures/get_device_state.json | 42 ---- .../fixtures/get_device_state_1.json | 42 ---- .../fixtures/get_devices.json | 22 -- .../fixtures/get_sites.json | 1 - .../snapshots/test_cover.ambr | 201 --------------- .../snapshots/test_diagnostics.ambr | 83 ------- .../snapshots/test_light.ambr | 233 ------------------ .../linear_garage_door/test_config_flow.py | 132 ---------- .../linear_garage_door/test_cover.py | 120 --------- .../linear_garage_door/test_diagnostics.py | 29 --- .../linear_garage_door/test_init.py | 135 ---------- .../linear_garage_door/test_light.py | 126 ---------- 31 files changed, 1861 deletions(-) delete mode 100644 homeassistant/components/linear_garage_door/__init__.py delete mode 100644 homeassistant/components/linear_garage_door/config_flow.py delete mode 100644 homeassistant/components/linear_garage_door/const.py delete mode 100644 homeassistant/components/linear_garage_door/coordinator.py delete mode 100644 homeassistant/components/linear_garage_door/cover.py delete mode 100644 homeassistant/components/linear_garage_door/diagnostics.py delete mode 100644 homeassistant/components/linear_garage_door/entity.py delete mode 100644 homeassistant/components/linear_garage_door/light.py delete mode 100644 homeassistant/components/linear_garage_door/manifest.json delete mode 100644 homeassistant/components/linear_garage_door/strings.json delete mode 100644 tests/components/linear_garage_door/__init__.py delete mode 100644 tests/components/linear_garage_door/conftest.py delete mode 100644 tests/components/linear_garage_door/fixtures/get_device_state.json delete mode 100644 tests/components/linear_garage_door/fixtures/get_device_state_1.json delete mode 100644 tests/components/linear_garage_door/fixtures/get_devices.json delete mode 100644 tests/components/linear_garage_door/fixtures/get_sites.json delete mode 100644 tests/components/linear_garage_door/snapshots/test_cover.ambr delete mode 100644 tests/components/linear_garage_door/snapshots/test_diagnostics.ambr delete mode 100644 tests/components/linear_garage_door/snapshots/test_light.ambr delete mode 100644 tests/components/linear_garage_door/test_config_flow.py delete mode 100644 tests/components/linear_garage_door/test_cover.py delete mode 100644 tests/components/linear_garage_door/test_diagnostics.py delete mode 100644 tests/components/linear_garage_door/test_init.py delete mode 100644 tests/components/linear_garage_door/test_light.py diff --git a/.strict-typing b/.strict-typing index c125e85bbfc..98973c89a5a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -310,7 +310,6 @@ homeassistant.components.letpot.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* -homeassistant.components.linear_garage_door.* homeassistant.components.linkplay.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* diff --git a/CODEOWNERS b/CODEOWNERS index 84a07305d36..d52349d49e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -862,8 +862,6 @@ build.json @home-assistant/supervisor /tests/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core -/homeassistant/components/linear_garage_door/ @IceBotYT -/tests/components/linear_garage_door/ @IceBotYT /homeassistant/components/linkplay/ @Velleman /tests/components/linkplay/ @Velleman /homeassistant/components/linux_battery/ @fabaff diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py deleted file mode 100644 index a80aa99628b..00000000000 --- a/homeassistant/components/linear_garage_door/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The Linear Garage Door integration.""" - -from __future__ import annotations - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from .const import DOMAIN -from .coordinator import LinearConfigEntry, LinearUpdateCoordinator - -PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] - - -async def async_setup_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool: - """Set up Linear Garage Door from a config entry.""" - - ir.async_create_issue( - hass, - DOMAIN, - DOMAIN, - breaks_in_ha_version="2025.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_integration", - translation_placeholders={ - "nice_go": "https://www.home-assistant.io/integrations/linear_garage_door", - "entries": "/config/integrations/integration/linear_garage_door", - }, - ) - - coordinator = LinearUpdateCoordinator(hass, entry) - - await coordinator.async_config_entry_first_refresh() - - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_remove_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> None: - """Remove a config entry.""" - if not hass.config_entries.async_loaded_entries(DOMAIN): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - # Remove any remaining disabled or ignored entries - for _entry in hass.config_entries.async_entries(DOMAIN): - hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py deleted file mode 100644 index 2cfd0af6a8f..00000000000 --- a/homeassistant/components/linear_garage_door/config_flow.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Config flow for Linear Garage Door integration.""" - -from __future__ import annotations - -from collections.abc import Collection, Mapping, Sequence -import logging -from typing import Any -import uuid - -from linear_garage_door import Linear -from linear_garage_door.errors import InvalidLoginError -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = { - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, -} - - -async def validate_input( - hass: HomeAssistant, - data: dict[str, str], -) -> dict[str, Sequence[Collection[str]]]: - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - - hub = Linear() - - device_id = str(uuid.uuid4()) - try: - await hub.login( - data["email"], - data["password"], - device_id=device_id, - client_session=async_get_clientsession(hass), - ) - - sites = await hub.get_sites() - except InvalidLoginError as err: - raise InvalidAuth from err - finally: - await hub.close() - - return { - "email": data["email"], - "password": data["password"], - "sites": sites, - "device_id": device_id, - } - - -class LinearGarageDoorConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Linear Garage Door.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize the config flow.""" - self.data: dict[str, Sequence[Collection[str]]] = {} - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - data_schema = vol.Schema(STEP_USER_DATA_SCHEMA) - - if user_input is None: - return self.async_show_form(step_id="user", data_schema=data_schema) - - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = info - - # Check if we are reauthenticating - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={ - CONF_EMAIL: self.data["email"], - CONF_PASSWORD: self.data["password"], - }, - ) - - return await self.async_step_site() - - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors - ) - - async def async_step_site( - self, - user_input: dict[str, Any] | None = None, - ) -> ConfigFlowResult: - """Handle the site step.""" - - if isinstance(self.data["sites"], list): - sites: list[dict[str, str]] = self.data["sites"] - - if not user_input: - return self.async_show_form( - step_id="site", - data_schema=vol.Schema( - { - vol.Required("site"): vol.In( - {site["id"]: site["name"] for site in sites} - ) - } - ), - ) - - site_id = user_input["site"] - - site_name = next(site["name"] for site in sites if site["id"] == site_id) - - await self.async_set_unique_id(site_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=site_name, - data={ - "site_id": site_id, - "email": self.data["email"], - "password": self.data["password"], - "device_id": self.data["device_id"], - }, - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Reauth in case of a password change or other error.""" - return await self.async_step_user() - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class InvalidDeviceID(HomeAssistantError): - """Error to indicate there is invalid device ID.""" diff --git a/homeassistant/components/linear_garage_door/const.py b/homeassistant/components/linear_garage_door/const.py deleted file mode 100644 index 7b3625c7c67..00000000000 --- a/homeassistant/components/linear_garage_door/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Linear Garage Door integration.""" - -DOMAIN = "linear_garage_door" diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py deleted file mode 100644 index 3844e1ae7de..00000000000 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ /dev/null @@ -1,86 +0,0 @@ -"""DataUpdateCoordinator for Linear.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import Any, cast - -from linear_garage_door import Linear -from linear_garage_door.errors import InvalidLoginError - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -type LinearConfigEntry = ConfigEntry[LinearUpdateCoordinator] - - -@dataclass -class LinearDevice: - """Linear device dataclass.""" - - name: str - subdevices: dict[str, dict[str, str]] - - -class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): - """DataUpdateCoordinator for Linear.""" - - _devices: list[dict[str, Any]] | None = None - config_entry: LinearConfigEntry - - def __init__(self, hass: HomeAssistant, config_entry: LinearConfigEntry) -> None: - """Initialize DataUpdateCoordinator for Linear.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="Linear Garage Door", - update_interval=timedelta(seconds=60), - ) - self.site_id = config_entry.data["site_id"] - - async def _async_update_data(self) -> dict[str, LinearDevice]: - """Get the data for Linear.""" - - async def update_data(linear: Linear) -> dict[str, Any]: - if not self._devices: - self._devices = await linear.get_devices(self.site_id) - - data = {} - - for device in self._devices: - device_id = str(device["id"]) - state = await linear.get_device_state(device_id) - data[device_id] = LinearDevice(cast(str, device["name"]), state) - return data - - return await self.execute(update_data) - - async def execute[_T](self, func: Callable[[Linear], Awaitable[_T]]) -> _T: - """Execute an API call.""" - linear = Linear() - try: - await linear.login( - email=self.config_entry.data["email"], - password=self.config_entry.data["password"], - device_id=self.config_entry.data["device_id"], - client_session=async_get_clientsession(self.hass), - ) - except InvalidLoginError as err: - if ( - str(err) - == "Login error: Login provided is invalid, please check the email and password" - ): - raise ConfigEntryAuthFailed from err - raise ConfigEntryNotReady from err - result = await func(linear) - await linear.close() - return result diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py deleted file mode 100644 index 1f6c0999531..00000000000 --- a/homeassistant/components/linear_garage_door/cover.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Cover entity for Linear Garage Doors.""" - -from datetime import timedelta -from typing import Any - -from homeassistant.components.cover import ( - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import LinearConfigEntry -from .entity import LinearEntity - -SUPPORTED_SUBDEVICES = ["GDO"] -PARALLEL_UPDATES = 1 -SCAN_INTERVAL = timedelta(seconds=10) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: LinearConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Linear Garage Door cover.""" - coordinator = config_entry.runtime_data - - async_add_entities( - LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id) - for device_id, device_data in coordinator.data.items() - for sub_device_id in device_data.subdevices - if sub_device_id in SUPPORTED_SUBDEVICES - ) - - -class LinearCoverEntity(LinearEntity, CoverEntity): - """Representation of a Linear cover.""" - - _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - _attr_name = None - _attr_device_class = CoverDeviceClass.GARAGE - - @property - def is_closed(self) -> bool: - """Return if cover is closed.""" - return self.sub_device.get("Open_B") == "false" - - @property - def is_opened(self) -> bool: - """Return if cover is open.""" - return self.sub_device.get("Open_B") == "true" - - @property - def is_opening(self) -> bool: - """Return if cover is opening.""" - return self.sub_device.get("Opening_P") == "0" - - @property - def is_closing(self) -> bool: - """Return if cover is closing.""" - return self.sub_device.get("Opening_P") == "100" - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close the garage door.""" - if self.is_closed: - return - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Close" - ) - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the garage door.""" - if self.is_opened: - return - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Open" - ) - ) diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py deleted file mode 100644 index ff5ca5639bf..00000000000 --- a/homeassistant/components/linear_garage_door/diagnostics.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Diagnostics support for Linear Garage Door.""" - -from __future__ import annotations - -from dataclasses import asdict -from typing import Any - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant - -from .coordinator import LinearConfigEntry - -TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: LinearConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data - - return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "coordinator_data": { - device_id: asdict(device_data) - for device_id, device_data in coordinator.data.items() - }, - } diff --git a/homeassistant/components/linear_garage_door/entity.py b/homeassistant/components/linear_garage_door/entity.py deleted file mode 100644 index a7adf95f82e..00000000000 --- a/homeassistant/components/linear_garage_door/entity.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Base entity for Linear.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import LinearDevice, LinearUpdateCoordinator - - -class LinearEntity(CoordinatorEntity[LinearUpdateCoordinator]): - """Common base for Linear entities.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: LinearUpdateCoordinator, - device_id: str, - device_name: str, - sub_device_id: str, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{device_id}-{sub_device_id}" - self._device_id = device_id - self._sub_device_id = sub_device_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - name=device_name, - manufacturer="Linear", - model="Garage Door Opener", - ) - - @property - def linear_device(self) -> LinearDevice: - """Return the Linear device.""" - return self.coordinator.data[self._device_id] - - @property - def sub_device(self) -> dict[str, str]: - """Return the subdevice.""" - return self.linear_device.subdevices[self._sub_device_id] diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py deleted file mode 100644 index 59243817fbb..00000000000 --- a/homeassistant/components/linear_garage_door/light.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Linear garage door light.""" - -from typing import Any - -from linear_garage_door import Linear - -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import LinearConfigEntry -from .entity import LinearEntity - -SUPPORTED_SUBDEVICES = ["Light"] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: LinearConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Linear Garage Door cover.""" - coordinator = config_entry.runtime_data - data = coordinator.data - - async_add_entities( - LinearLightEntity( - device_id=device_id, - device_name=data[device_id].name, - sub_device_id=subdev, - coordinator=coordinator, - ) - for device_id in data - for subdev in data[device_id].subdevices - if subdev in SUPPORTED_SUBDEVICES - ) - - -class LinearLightEntity(LinearEntity, LightEntity): - """Light for Linear devices.""" - - _attr_color_mode = ColorMode.BRIGHTNESS - _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - _attr_translation_key = "light" - - @property - def is_on(self) -> bool: - """Return if the light is on or not.""" - return bool(self.sub_device["On_B"] == "true") - - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return round(int(self.sub_device["On_P"]) / 100 * 255) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the light.""" - - async def _turn_on(linear: Linear) -> None: - """Turn on the light.""" - if not kwargs: - await linear.operate_device(self._device_id, self._sub_device_id, "On") - elif ATTR_BRIGHTNESS in kwargs: - brightness = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) - await linear.operate_device( - self._device_id, self._sub_device_id, f"DimPercent:{brightness}" - ) - - await self.coordinator.execute(_turn_on) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the light.""" - - await self.coordinator.execute( - lambda linear: linear.operate_device( - self._device_id, self._sub_device_id, "Off" - ) - ) diff --git a/homeassistant/components/linear_garage_door/manifest.json b/homeassistant/components/linear_garage_door/manifest.json deleted file mode 100644 index f1eb4302cf0..00000000000 --- a/homeassistant/components/linear_garage_door/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "linear_garage_door", - "name": "Linear Garage Door", - "codeowners": ["@IceBotYT"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/linear_garage_door", - "iot_class": "cloud_polling", - "requirements": ["linear-garage-door==0.2.9"] -} diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json deleted file mode 100644 index 40ffcf22e8d..00000000000 --- a/homeassistant/components/linear_garage_door/strings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "light": { - "light": { - "name": "[%key:component::light::title%]" - } - } - }, - "issues": { - "deprecated_integration": { - "title": "The Linear Garage Door integration will be removed", - "description": "The Linear Garage Door integration will be removed as it has been replaced by the [Nice G.O.]({nice_go}) integration. Please migrate to the new integration.\n\nTo resolve this issue, please remove all Linear Garage Door entries from your configuration and add the new Nice G.O. integration. [Click here to see your existing Linear Garage Door integration entries]({entries})." - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5816a0ddbd9..8de75b21bba 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -348,7 +348,6 @@ FLOWS = { "lg_thinq", "lidarr", "lifx", - "linear_garage_door", "linkplay", "litejet", "litterrobot", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c606d79f2c5..e9a8f46a496 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3512,12 +3512,6 @@ "integration_type": "virtual", "supported_by": "idasen_desk" }, - "linear_garage_door": { - "name": "Linear Garage Door", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "linkedgo": { "name": "LinkedGo", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 8482138cc45..91c75beb64a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2856,16 +2856,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.linear_garage_door.*] -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.linkplay.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1e27ad4ded9..c29a2c81b08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1363,9 +1363,6 @@ lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 -# homeassistant.components.linear_garage_door -linear-garage-door==0.2.9 - # homeassistant.components.linode linode-api==4.1.9b1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index caf90997fa5..dc27d14f1ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1170,9 +1170,6 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 -# homeassistant.components.linear_garage_door -linear-garage-door==0.2.9 - # homeassistant.components.livisi livisi==0.0.25 diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py deleted file mode 100644 index 67bd1ee2da2..00000000000 --- a/tests/components/linear_garage_door/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Tests for the Linear Garage Door integration.""" - -from unittest.mock import patch - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] -) -> None: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.linear_garage_door.PLATFORMS", - platforms, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/linear_garage_door/conftest.py b/tests/components/linear_garage_door/conftest.py deleted file mode 100644 index 4ed7662e5d0..00000000000 --- a/tests/components/linear_garage_door/conftest.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Common fixtures for the Linear Garage Door tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, patch - -import pytest - -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.linear_garage_door.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def mock_linear() -> Generator[AsyncMock]: - """Mock a Linear Garage Door client.""" - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear", - autospec=True, - ) as mock_client, - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear", - new=mock_client, - ), - ): - client = mock_client.return_value - client.login.return_value = True - client.get_devices.return_value = load_json_array_fixture( - "get_devices.json", DOMAIN - ) - client.get_sites.return_value = load_json_array_fixture( - "get_sites.json", DOMAIN - ) - device_states = load_json_object_fixture("get_device_state.json", DOMAIN) - client.get_device_state.side_effect = lambda device_id: device_states[device_id] - yield client - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Mock a config entry.""" - return MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201e", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) diff --git a/tests/components/linear_garage_door/fixtures/get_device_state.json b/tests/components/linear_garage_door/fixtures/get_device_state.json deleted file mode 100644 index 14247610e06..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_device_state.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "test1": { - "GDO": { - "Open_B": "true", - "Open_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - }, - "test2": { - "GDO": { - "Open_B": "false", - "Open_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test3": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test4": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - } -} diff --git a/tests/components/linear_garage_door/fixtures/get_device_state_1.json b/tests/components/linear_garage_door/fixtures/get_device_state_1.json deleted file mode 100644 index 1f41d4fd153..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_device_state_1.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "test1": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test2": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - }, - "test3": { - "GDO": { - "Open_B": "false", - "Opening_P": "0" - }, - "Light": { - "On_B": "false", - "On_P": "0" - } - }, - "test4": { - "GDO": { - "Open_B": "true", - "Opening_P": "100" - }, - "Light": { - "On_B": "true", - "On_P": "100" - } - } -} diff --git a/tests/components/linear_garage_door/fixtures/get_devices.json b/tests/components/linear_garage_door/fixtures/get_devices.json deleted file mode 100644 index da6eeaf7448..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_devices.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "id": "test1", - "name": "Test Garage 1", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test2", - "name": "Test Garage 2", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test3", - "name": "Test Garage 3", - "subdevices": ["GDO", "Light"] - }, - { - "id": "test4", - "name": "Test Garage 4", - "subdevices": ["GDO", "Light"] - } -] diff --git a/tests/components/linear_garage_door/fixtures/get_sites.json b/tests/components/linear_garage_door/fixtures/get_sites.json deleted file mode 100644 index 2b0a49b9007..00000000000 --- a/tests/components/linear_garage_door/fixtures/get_sites.json +++ /dev/null @@ -1 +0,0 @@ -[{ "id": "test-site-id", "name": "test-site-name" }] diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/linear_garage_door/snapshots/test_cover.ambr deleted file mode 100644 index dc3df6684bc..00000000000 --- a/tests/components/linear_garage_door/snapshots/test_cover.ambr +++ /dev/null @@ -1,201 +0,0 @@ -# serializer version: 1 -# name: test_covers[cover.test_garage_1-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.test_garage_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'test1-GDO', - 'unit_of_measurement': None, - }) -# --- -# name: test_covers[cover.test_garage_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 1', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_garage_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'open', - }) -# --- -# name: test_covers[cover.test_garage_2-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.test_garage_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'test2-GDO', - 'unit_of_measurement': None, - }) -# --- -# name: test_covers[cover.test_garage_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 2', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_garage_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'closed', - }) -# --- -# name: test_covers[cover.test_garage_3-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.test_garage_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'test3-GDO', - 'unit_of_measurement': None, - }) -# --- -# name: test_covers[cover.test_garage_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 3', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_garage_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'opening', - }) -# --- -# name: test_covers[cover.test_garage_4-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.test_garage_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'test4-GDO', - 'unit_of_measurement': None, - }) -# --- -# name: test_covers[cover.test_garage_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'garage', - 'friendly_name': 'Test Garage 4', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.test_garage_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'closing', - }) -# --- diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr deleted file mode 100644 index db82f41eb73..00000000000 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ /dev/null @@ -1,83 +0,0 @@ -# serializer version: 1 -# name: test_entry_diagnostics - dict({ - 'coordinator_data': dict({ - 'test1': dict({ - 'name': 'Test Garage 1', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'true', - 'Open_P': '100', - }), - 'Light': dict({ - 'On_B': 'true', - 'On_P': '100', - }), - }), - }), - 'test2': dict({ - 'name': 'Test Garage 2', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'false', - 'Open_P': '0', - }), - 'Light': dict({ - 'On_B': 'false', - 'On_P': '0', - }), - }), - }), - 'test3': dict({ - 'name': 'Test Garage 3', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'false', - 'Opening_P': '0', - }), - 'Light': dict({ - 'On_B': 'false', - 'On_P': '0', - }), - }), - }), - 'test4': dict({ - 'name': 'Test Garage 4', - 'subdevices': dict({ - 'GDO': dict({ - 'Open_B': 'true', - 'Opening_P': '100', - }), - 'Light': dict({ - 'On_B': 'true', - 'On_P': '100', - }), - }), - }), - }), - 'entry': dict({ - 'data': dict({ - 'device_id': 'test-uuid', - 'email': '**REDACTED**', - 'password': '**REDACTED**', - 'site_id': 'test-site-id', - }), - 'disabled_by': None, - 'discovery_keys': dict({ - }), - 'domain': 'linear_garage_door', - 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', - 'minor_version': 1, - 'options': dict({ - }), - 'pref_disable_new_entities': False, - 'pref_disable_polling': False, - 'source': 'user', - 'subentries': list([ - ]), - 'title': 'test-site-name', - 'unique_id': None, - 'version': 1, - }), - }) -# --- diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/linear_garage_door/snapshots/test_light.ambr deleted file mode 100644 index 930d78d4706..00000000000 --- a/tests/components/linear_garage_door/snapshots/test_light.ambr +++ /dev/null @@ -1,233 +0,0 @@ -# serializer version: 1 -# name: test_data[light.test_garage_1_light-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.test_garage_1_light', - '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': 'Light', - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test1-Light', - 'unit_of_measurement': None, - }) -# --- -# name: test_data[light.test_garage_1_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'friendly_name': 'Test Garage 1 Light', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.test_garage_1_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_data[light.test_garage_2_light-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.test_garage_2_light', - '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': 'Light', - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test2-Light', - 'unit_of_measurement': None, - }) -# --- -# name: test_data[light.test_garage_2_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Test Garage 2 Light', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.test_garage_2_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_data[light.test_garage_3_light-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.test_garage_3_light', - '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': 'Light', - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test3-Light', - 'unit_of_measurement': None, - }) -# --- -# name: test_data[light.test_garage_3_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Test Garage 3 Light', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.test_garage_3_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_data[light.test_garage_4_light-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.test_garage_4_light', - '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': 'Light', - 'platform': 'linear_garage_door', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light', - 'unique_id': 'test4-Light', - 'unit_of_measurement': None, - }) -# --- -# name: test_data[light.test_garage_4_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'friendly_name': 'Test Garage 4 Light', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.test_garage_4_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py deleted file mode 100644 index 64bdc589194..00000000000 --- a/tests/components/linear_garage_door/test_config_flow.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Test the Linear Garage Door config flow.""" - -from unittest.mock import AsyncMock, patch - -from linear_garage_door.errors import InvalidLoginError -import pytest - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form( - hass: HomeAssistant, mock_linear: AsyncMock, mock_setup_entry: AsyncMock -) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert not result["errors"] - - with patch( - "uuid.uuid4", - return_value="test-uuid", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"site": "test-site-id"} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-site-name" - assert result["data"] == { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_reauth( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test reauthentication.""" - 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"] == "user" - - with patch( - "uuid.uuid4", - return_value="test-uuid", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "new-email", - CONF_PASSWORD: "new-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - - assert mock_config_entry.data == { - CONF_EMAIL: "new-email", - CONF_PASSWORD: "new-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - } - - -@pytest.mark.parametrize( - ("side_effect", "expected_error"), - [(InvalidLoginError, "invalid_auth"), (Exception, "unknown")], -) -async def test_form_exceptions( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_setup_entry: AsyncMock, - side_effect: Exception, - expected_error: str, -) -> None: - """Test we handle invalid auth.""" - mock_linear.login.side_effect = side_effect - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": expected_error} - mock_linear.login.side_effect = None - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - }, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"site": "test-site-id"} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py deleted file mode 100644 index c031db88180..00000000000 --- a/tests/components/linear_garage_door/test_cover.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Test Linear Garage Door cover.""" - -from datetime import timedelta -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.cover import ( - DOMAIN as COVER_DOMAIN, - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - CoverState, -) -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_json_object_fixture, - snapshot_platform, -) - - -async def test_covers( - hass: HomeAssistant, - mock_linear: AsyncMock, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that data gets parsed and returned appropriately.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_open_cover( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that opening the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 0 - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_close_cover( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that closing the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 0 - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_update_cover_state( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that closing the cover works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.COVER]) - - assert hass.states.get("cover.test_garage_1").state == CoverState.OPEN - assert hass.states.get("cover.test_garage_2").state == CoverState.CLOSED - - device_states = await async_load_json_object_fixture( - hass, "get_device_state_1.json", DOMAIN - ) - mock_linear.get_device_state.side_effect = lambda device_id: device_states[ - device_id - ] - - freezer.tick(timedelta(seconds=60)) - async_fire_time_changed(hass) - - assert hass.states.get("cover.test_garage_1").state == CoverState.CLOSING - assert hass.states.get("cover.test_garage_2").state == CoverState.OPENING diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py deleted file mode 100644 index f51bb0a366c..00000000000 --- a/tests/components/linear_garage_door/test_diagnostics.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Test diagnostics of Linear Garage Door.""" - -from unittest.mock import AsyncMock - -from syrupy.assertion import SnapshotAssertion -from syrupy.filters import props - -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_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test config entry diagnostics.""" - await setup_integration(hass, mock_config_entry, []) - result = await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) - assert result == snapshot(exclude=props("created_at", "modified_at")) diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py deleted file mode 100644 index 2693eda60bb..00000000000 --- a/tests/components/linear_garage_door/test_init.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Test Linear Garage Door init.""" - -from unittest.mock import AsyncMock - -from linear_garage_door import InvalidLoginError -import pytest - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import ( - SOURCE_IGNORE, - ConfigEntryDisabler, - ConfigEntryState, -) -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from . import setup_integration - -from tests.common import MockConfigEntry - - -async def test_unload_entry( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test the unload entry.""" - - await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -@pytest.mark.parametrize( - ("side_effect", "entry_state"), - [ - ( - InvalidLoginError( - "Login provided is invalid, please check the email and password" - ), - ConfigEntryState.SETUP_ERROR, - ), - (InvalidLoginError("Invalid login"), ConfigEntryState.SETUP_RETRY), - ], -) -async def test_setup_failure( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - side_effect: Exception, - entry_state: ConfigEntryState, -) -> None: - """Test reauth trigger setup.""" - - mock_linear.login.side_effect = side_effect - - await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state == entry_state - - -async def test_repair_issue( - hass: HomeAssistant, - mock_linear: AsyncMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the Linear Garage Door configuration entry loading/unloading handles the repair.""" - config_entry_1 = MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201e", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - await setup_integration(hass, config_entry_1, []) - assert config_entry_1.state is ConfigEntryState.LOADED - - # Add a second one - config_entry_2 = MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201f", - title="test-site-name", - data={ - CONF_EMAIL: "test-email", - CONF_PASSWORD: "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - await setup_integration(hass, config_entry_2, []) - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - - # Add an ignored entry - config_entry_3 = MockConfigEntry( - source=SOURCE_IGNORE, - domain=DOMAIN, - ) - config_entry_3.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_3.entry_id) - await hass.async_block_till_done() - - assert config_entry_3.state is ConfigEntryState.NOT_LOADED - - # Add a disabled entry - config_entry_4 = MockConfigEntry( - disabled_by=ConfigEntryDisabler.USER, - domain=DOMAIN, - ) - config_entry_4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_4.entry_id) - await hass.async_block_till_done() - - assert config_entry_4.state is ConfigEntryState.NOT_LOADED - - # Remove the first one - await hass.config_entries.async_remove(config_entry_1.entry_id) - await hass.async_block_till_done() - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - # Remove the second one - await hass.config_entries.async_remove(config_entry_2.entry_id) - await hass.async_block_till_done() - assert config_entry_1.state is ConfigEntryState.NOT_LOADED - assert config_entry_2.state is ConfigEntryState.NOT_LOADED - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None - - # Check the ignored and disabled entries are removed - assert not hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/linear_garage_door/test_light.py b/tests/components/linear_garage_door/test_light.py deleted file mode 100644 index 1985b27aacd..00000000000 --- a/tests/components/linear_garage_door/test_light.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Test Linear Garage Door light.""" - -from datetime import timedelta -from unittest.mock import AsyncMock - -from freezegun.api import FrozenDateTimeFactory -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.light import ( - DOMAIN as LIGHT_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.components.linear_garage_door import DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_BRIGHTNESS, - STATE_OFF, - STATE_ON, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_json_object_fixture, - snapshot_platform, -) - - -async def test_data( - hass: HomeAssistant, - mock_linear: AsyncMock, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that data gets parsed and returned appropriately.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -async def test_turn_on( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning on the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_garage_2_light"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_turn_on_with_brightness( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning on the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_garage_2_light", CONF_BRIGHTNESS: 50}, - blocking=True, - ) - - mock_linear.operate_device.assert_called_once_with( - "test2", "Light", "DimPercent:20" - ) - - -async def test_turn_off( - hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test that turning off the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_garage_1_light"}, - blocking=True, - ) - - assert mock_linear.operate_device.call_count == 1 - - -async def test_update_light_state( - hass: HomeAssistant, - mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, -) -> None: - """Test that turning off the light works as intended.""" - - await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) - - assert hass.states.get("light.test_garage_1_light").state == STATE_ON - assert hass.states.get("light.test_garage_2_light").state == STATE_OFF - - device_states = await async_load_json_object_fixture( - hass, "get_device_state_1.json", DOMAIN - ) - mock_linear.get_device_state.side_effect = lambda device_id: device_states[ - device_id - ] - - freezer.tick(timedelta(seconds=60)) - async_fire_time_changed(hass) - - assert hass.states.get("light.test_garage_1_light").state == STATE_OFF - assert hass.states.get("light.test_garage_2_light").state == STATE_ON From 4cb2af4d08e553cdd5818419ac8c5f2e89d56421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 8 Aug 2025 13:47:13 +0200 Subject: [PATCH 1013/1118] Add select platform to LetPot integration (#150212) --- homeassistant/components/letpot/__init__.py | 1 + homeassistant/components/letpot/icons.json | 17 ++ homeassistant/components/letpot/select.py | 163 +++++++++++++ homeassistant/components/letpot/strings.json | 23 ++ tests/components/letpot/__init__.py | 2 +- tests/components/letpot/conftest.py | 24 +- .../letpot/snapshots/test_select.ambr | 229 ++++++++++++++++++ tests/components/letpot/test_select.py | 102 ++++++++ 8 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/letpot/select.py create mode 100644 tests/components/letpot/snapshots/test_select.ambr create mode 100644 tests/components/letpot/test_select.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 4b84a023675..7bcb04b2b4d 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -25,6 +25,7 @@ from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 43541b57150..1f5e79b04dd 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -20,6 +20,23 @@ } } }, + "select": { + "display_temperature_unit": { + "default": "mdi:thermometer-lines" + }, + "light_brightness": { + "default": "mdi:brightness-6", + "state": { + "high": "mdi:brightness-7" + } + }, + "light_mode": { + "default": "mdi:sprout", + "state": { + "flower": "mdi:flower" + } + } + }, "sensor": { "water_level": { "default": "mdi:water-percent" diff --git a/homeassistant/components/letpot/select.py b/homeassistant/components/letpot/select.py new file mode 100644 index 00000000000..0a9f6b07046 --- /dev/null +++ b/homeassistant/components/letpot/select.py @@ -0,0 +1,163 @@ +"""Support for LetPot select entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import DeviceFeature, LightMode, TemperatureUnit + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription, exception_handler + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +class LightBrightnessLowHigh(StrEnum): + """Light brightness low/high model.""" + + LOW = "low" + HIGH = "high" + + +def _get_brightness_low_high_value(coordinator: LetPotDeviceCoordinator) -> str | None: + """Return brightness as low/high for a device which only has a low and high value.""" + brightness = coordinator.data.light_brightness + levels = coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ) + return ( + LightBrightnessLowHigh.LOW.value + if levels[0] == brightness + else LightBrightnessLowHigh.HIGH.value + ) + + +async def _set_brightness_low_high_value( + device_client: LetPotDeviceClient, serial: str, option: str +) -> None: + """Set brightness from low/high for a device which only has a low and high value.""" + levels = device_client.get_light_brightness_levels(serial) + await device_client.set_light_brightness( + serial, levels[0] if option == LightBrightnessLowHigh.LOW.value else levels[1] + ) + + +@dataclass(frozen=True, kw_only=True) +class LetPotSelectEntityDescription(LetPotEntityDescription, SelectEntityDescription): + """Describes a LetPot select entity.""" + + value_fn: Callable[[LetPotDeviceCoordinator], str | None] + set_value_fn: Callable[[LetPotDeviceClient, str, str], Coroutine[Any, Any, None]] + + +SELECTORS: tuple[LetPotSelectEntityDescription, ...] = ( + LetPotSelectEntityDescription( + key="display_temperature_unit", + translation_key="display_temperature_unit", + options=[x.name.lower() for x in TemperatureUnit], + value_fn=( + lambda coordinator: coordinator.data.temperature_unit.name.lower() + if coordinator.data.temperature_unit is not None + else None + ), + set_value_fn=( + lambda device_client, serial, option: device_client.set_temperature_unit( + serial, TemperatureUnit[option.upper()] + ) + ), + supported_fn=( + lambda coordinator: DeviceFeature.TEMPERATURE_SET_UNIT + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotSelectEntityDescription( + key="light_brightness_low_high", + translation_key="light_brightness", + options=[ + LightBrightnessLowHigh.LOW.value, + LightBrightnessLowHigh.HIGH.value, + ], + value_fn=_get_brightness_low_high_value, + set_value_fn=_set_brightness_low_high_value, + supported_fn=( + lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + entity_category=EntityCategory.CONFIG, + ), + LetPotSelectEntityDescription( + key="light_mode", + translation_key="light_mode", + options=[x.name.lower() for x in LightMode], + value_fn=( + lambda coordinator: coordinator.data.light_mode.name.lower() + if coordinator.data.light_mode is not None + else None + ), + set_value_fn=( + lambda device_client, serial, option: device_client.set_light_mode( + serial, LightMode[option.upper()] + ) + ), + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot select entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotSelectEntity(coordinator, description) + for description in SELECTORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotSelectEntity(LetPotEntity, SelectEntity): + """Defines a LetPot select entity.""" + + entity_description: LetPotSelectEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSelectEntityDescription, + ) -> None: + """Initialize LetPot select entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option.""" + return self.entity_description.value_fn(self.coordinator) + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + return await self.entity_description.set_value_fn( + self.coordinator.device_client, + self.coordinator.device.serial_number, + option, + ) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index cdc5a36a15f..6ebd79edf5d 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -49,6 +49,29 @@ "name": "Refill error" } }, + "select": { + "display_temperature_unit": { + "name": "Temperature unit on display", + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } + }, + "light_brightness": { + "name": "Light brightness", + "state": { + "low": "[%key:common::state::low%]", + "high": "[%key:common::state::high%]" + } + }, + "light_mode": { + "name": "Light mode", + "state": { + "flower": "Fruits/Flowers", + "vegetable": "Veggies/Herbs" + } + } + }, "sensor": { "water_level": { "name": "Water level" diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index d8be422899a..644b8e1580f 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -33,7 +33,7 @@ AUTHENTICATION = AuthenticationInfo( MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), - light_brightness=500, + light_brightness=750, light_mode=LightMode.VEGETABLE, light_schedule_end=datetime.time(18, 0), light_schedule_start=datetime.time(8, 0), diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 03ce2ec4a0d..4abf917cb9f 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -49,6 +49,16 @@ def _mock_device_features(device_type: str) -> DeviceFeature: | DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS ) + if device_type == "LPH62": + return ( + DeviceFeature.CATEGORY_HYDROPONIC_GARDEN + | DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.TEMPERATURE + | DeviceFeature.TEMPERATURE_SET_UNIT + | DeviceFeature.WATER_LEVEL + ) if device_type == "LPH63": return ( DeviceFeature.CATEGORY_HYDROPONIC_GARDEN @@ -66,11 +76,20 @@ def _mock_device_status(device_type: str) -> LetPotDeviceStatus: """Return mock device status for the given type.""" if device_type == "LPH31": return SE_STATUS - if device_type == "LPH63": + if device_type in {"LPH62", "LPH63"}: return MAX_STATUS raise ValueError(f"No mock data for device type {device_type}") +def _mock_light_brightness_levels(device_type: str) -> list[int]: + """Return mock brightness levels for the given type.""" + if device_type == "LPH31": + return [500, 1000] + if device_type in {"LPH62", "LPH63"}: + return [125, 250, 375, 500, 625, 750, 875, 1000] + raise ValueError(f"No mock data for device type {device_type}") + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -134,6 +153,9 @@ def mock_device_client() -> Generator[AsyncMock]: device_client.device_info.side_effect = lambda serial: _mock_device_info( serial[:5] ) + device_client.get_light_brightness_levels.side_effect = ( + lambda serial: _mock_light_brightness_levels(serial[:5]) + ) device_client.get_current_status.side_effect = get_current_status_side_effect device_client.request_status_update.side_effect = request_status_side_effect device_client.subscribe.side_effect = subscribe_side_effect diff --git a/tests/components/letpot/snapshots/test_select.ambr b/tests/components/letpot/snapshots/test_select.ambr new file mode 100644 index 00000000000..5d9ddf0d0d3 --- /dev/null +++ b/tests/components/letpot/snapshots/test_select.ambr @@ -0,0 +1,229 @@ +# serializer version: 1 +# name: test_all_entities[LPH31][select.garden_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_brightness', + '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': 'Light brightness', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_brightness', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_light_brightness_low_high', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][select.garden_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light brightness', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.garden_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_all_entities[LPH31][select.garden_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'flower', + 'vegetable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_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': 'Light mode', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_light_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][select.garden_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light mode', + 'options': list([ + 'flower', + 'vegetable', + ]), + }), + 'context': , + 'entity_id': 'select.garden_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vegetable', + }) +# --- +# name: test_all_entities[LPH62][select.garden_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'flower', + 'vegetable', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_light_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': 'Light mode', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH62ABCD_light_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH62][select.garden_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light mode', + 'options': list([ + 'flower', + 'vegetable', + ]), + }), + 'context': , + 'entity_id': 'select.garden_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'vegetable', + }) +# --- +# name: test_all_entities[LPH62][select.garden_temperature_unit_on_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'fahrenheit', + 'celsius', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garden_temperature_unit_on_display', + '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': 'Temperature unit on display', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display_temperature_unit', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH62ABCD_display_temperature_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH62][select.garden_temperature_unit_on_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Temperature unit on display', + 'options': list([ + 'fahrenheit', + 'celsius', + ]), + }), + 'context': , + 'entity_id': 'select.garden_temperature_unit_on_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'celsius', + }) +# --- diff --git a/tests/components/letpot/test_select.py b/tests/components/letpot/test_select.py new file mode 100644 index 00000000000..d576ca6fca6 --- /dev/null +++ b/tests/components/letpot/test_select.py @@ -0,0 +1,102 @@ +"""Test select entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +from letpot.models import LightMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH62", "LPH31"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize("device_type", ["LPH31"]) +async def test_set_select( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + device_type: str, +) -> None: + """Test select entity set to value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.garden_light_brightness", + ATTR_OPTION: "high", + }, + blocking=True, + ) + + mock_device_client.set_light_brightness.assert_awaited_once_with( + f"{device_type}ABCD", 1000 + ) + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_select_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test select entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_mode.side_effect = exception + + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.garden_light_mode", + ATTR_OPTION: LightMode.FLOWER.name.lower(), + }, + blocking=True, + ) From 2948b1c58eeea94f2308d78eec685cd1faa1da61 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:56:44 +0200 Subject: [PATCH 1014/1118] Cleanup Tuya fixture files (#150190) --- .../tuya/fixtures/cl_3r8gc33pnqsxfe1g.json | 3 +-- .../components/tuya/fixtures/cl_cpbo62rn.json | 2 -- .../tuya/fixtures/cl_ebt12ypvexnixvtf.json | 3 +-- .../components/tuya/fixtures/cl_qqdxfdht.json | 2 -- .../components/tuya/fixtures/cl_zah67ekd.json | 1 - .../tuya/fixtures/clkg_nhyj64w2.json | 2 -- .../tuya/fixtures/co2bj_yrr3eiyiacm31ski.json | 2 -- .../tuya/fixtures/cs_ka2wfrdoogpvgzfi.json | 2 -- .../tuya/fixtures/cs_qhxmvae667uap4zh.json | 2 -- .../tuya/fixtures/cs_vmxuxszzjwp5smli.json | 2 -- .../tuya/fixtures/cs_zibqa9dutqyaxym2.json | 1 - .../tuya/fixtures/cwjwq_agwu93lr.json | 2 -- .../tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json | 2 -- .../tuya/fixtures/cwysj_z3rpyvznfcch99aa.json | 2 -- .../tuya/fixtures/cz_0g1fmqh6d5io7lcn.json | 2 -- .../tuya/fixtures/cz_2jxesipczks0kdct.json | 2 -- .../tuya/fixtures/cz_cuhokdii7ojyw8k2.json | 2 -- .../tuya/fixtures/cz_dntgh2ngvshfxpsz.json | 2 -- .../tuya/fixtures/cz_hj0a5c7ckzzexu8l.json | 2 -- .../tuya/fixtures/cz_t0a4hwsf8anfsadp.json | 2 -- .../tuya/fixtures/dc_l3bpgg8ibsagon4x.json | 2 -- .../tuya/fixtures/dj_8szt7whdvwpmxglk.json | 2 -- .../tuya/fixtures/dj_8y0aquaa8v6tho8w.json | 2 -- .../tuya/fixtures/dj_baf9tt9lb8t5uc7z.json | 2 -- .../tuya/fixtures/dj_d4g0fbsoaal841o6.json | 2 -- .../tuya/fixtures/dj_djnozmdyqyriow8z.json | 2 -- .../tuya/fixtures/dj_ekwolitfjhxn55js.json | 2 -- .../tuya/fixtures/dj_fuupmcr2mb1odkja.json | 2 -- .../tuya/fixtures/dj_hp6orhaqm6as3jnv.json | 2 -- .../tuya/fixtures/dj_hpc8ddyfv85haxa7.json | 2 -- .../tuya/fixtures/dj_iayz2jmtlipjnxj7.json | 2 -- .../tuya/fixtures/dj_idnfq7xbx8qewyoa.json | 2 -- .../tuya/fixtures/dj_ilddqqih3tucdk68.json | 2 -- .../tuya/fixtures/dj_j1bgp31cffutizub.json | 2 -- .../tuya/fixtures/dj_lmnt3uyltk1xffrt.json | 2 -- .../tuya/fixtures/dj_mki13ie507rlry4r.json | 2 -- .../tuya/fixtures/dj_nbumqpv8vz61enji.json | 2 -- .../tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json | 2 -- .../components/tuya/fixtures/dj_oe0cpnjg.json | 2 -- .../components/tuya/fixtures/dj_riwp3k79.json | 2 -- .../tuya/fixtures/dj_tmsloaroqavbucgn.json | 2 -- .../tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json | 2 -- .../tuya/fixtures/dj_vqwcnabamzrc2kab.json | 2 -- .../tuya/fixtures/dj_xokdfs6kh5ednakk.json | 2 -- .../tuya/fixtures/dj_zakhnlpdiu0ycdxn.json | 2 -- .../tuya/fixtures/dj_zav1pa32pyxray78.json | 2 -- .../tuya/fixtures/dj_zputiamzanuk6yky.json | 2 -- .../tuya/fixtures/dlq_kxdr6su0c55p7bbo.json | 2 -- .../tuya/fixtures/fs_g0ewlb1vmwqljzji.json | 2 -- .../tuya/fixtures/fs_ibytpo6fpnugft1c.json | 2 -- .../tuya/fixtures/gyd_lgekqfxdabipm3tn.json | 2 -- .../tuya/fixtures/hps_2aaelwxk.json | 2 -- .../tuya/fixtures/kg_gbm9ata1zrzaez4a.json | 2 -- .../tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json | 2 -- .../tuya/fixtures/kj_yrzylxax1qspdgpp.json | 2 -- .../tuya/fixtures/ks_j9fa8ahzac8uvlfl.json | 2 -- .../tuya/fixtures/kt_5wnlzekkstwcdsvm.json | 2 -- .../tuya/fixtures/ldcg_9kbbfeho.json | 2 -- .../tuya/fixtures/mal_gyitctrjj1kefxp2.json | 1 - .../tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json | 1 - .../tuya/fixtures/mzj_qavcakohisj5adyh.json | 2 -- .../tuya/fixtures/pc_t2afic7i3v1bwhfp.json | 2 -- .../tuya/fixtures/pc_trjopo1vdlt9q1tg.json | 2 -- .../tuya/fixtures/pir_3amxzozho9xp4mkh.json | 2 -- .../tuya/fixtures/pir_fcdjzz3s.json | 2 -- .../tuya/fixtures/pir_wqz93nrdomectyoz.json | 2 -- .../tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json | 2 -- .../tuya/fixtures/qxj_fsea1lat3vuktbt6.json | 2 -- .../tuya/fixtures/qxj_is2indt9nlth6esa.json | 2 -- .../tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json | 2 -- .../tuya/fixtures/sd_lr33znaodtyarrrz.json | 2 -- .../tuya/fixtures/sfkzq_o6dagifntoafakst.json | 2 -- .../tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json | 2 -- .../components/tuya/fixtures/sj_tgvtvdoc.json | 2 -- .../tuya/fixtures/sp_drezasavompxpcgm.json | 2 -- .../tuya/fixtures/sp_rjKXWRohlvOTyLBu.json | 2 -- .../tuya/fixtures/sp_sdd5f5f2dl5wydjf.json | 2 -- .../tuya/fixtures/tdq_1aegphq4yfd50e6b.json | 2 -- .../tuya/fixtures/tdq_9htyiowaf5rtdhrv.json | 2 -- .../tuya/fixtures/tdq_cq1p0nt0a4rixnex.json | 2 -- .../tuya/fixtures/tdq_nockvv2k39vbrxxk.json | 2 -- .../tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json | 2 -- .../tuya/fixtures/tdq_uoa3mayicscacseb.json | 2 -- .../tuya/fixtures/tyndj_pyakuuoc.json | 2 -- .../tuya/fixtures/wfcon_b25mh8sxawsgndck.json | 2 -- .../tuya/fixtures/wg2_nwxr8qcu4seltoro.json | 2 -- .../components/tuya/fixtures/wk_6kijc7nd.json | 2 -- .../components/tuya/fixtures/wk_aqoouq7x.json | 2 -- .../tuya/fixtures/wk_fi6dne5tu4t1nm6j.json | 2 -- .../tuya/fixtures/wk_gogb05wrtredz3bs.json | 2 -- .../tuya/fixtures/wk_y5obtqhuztqsf2mj.json | 2 -- .../tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json | 2 -- .../tuya/fixtures/ydkt_jevroj5aguwdbs2e.json | 2 -- .../tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json | 2 -- .../tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json | 2 -- .../tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json | 3 +-- .../tuya/fixtures/zndb_4ggkyflayu1h1ho9.json | 2 -- .../tuya/fixtures/zndb_ze8faryrxr0glqnn.json | 2 -- .../tuya/fixtures/zwjcy_myd45weu.json | 2 -- tests/components/tuya/test_init.py | 23 +++++++++++++++++-- 100 files changed, 24 insertions(+), 196 deletions(-) diff --git a/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json index de6c23a1c14..189938aa4f0 100644 --- a/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json +++ b/tests/components/tuya/fixtures/cl_3r8gc33pnqsxfe1g.json @@ -118,6 +118,5 @@ "countdown": "cancel", "countdown_left": 0, "time_total": 25400 - }, - "terminal_id": "REDACTED" + } } diff --git a/tests/components/tuya/fixtures/cl_cpbo62rn.json b/tests/components/tuya/fixtures/cl_cpbo62rn.json index a5ed8e4b580..b52bb31f588 100644 --- a/tests/components/tuya/fixtures/cl_cpbo62rn.json +++ b/tests/components/tuya/fixtures/cl_cpbo62rn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf216113c71bf01a18jtl0", "name": "blinds", "category": "cl", "product_id": "cpbo62rn", diff --git a/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json index 4b15a27bfd5..fd0ff1fb181 100644 --- a/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json +++ b/tests/components/tuya/fixtures/cl_ebt12ypvexnixvtf.json @@ -53,6 +53,5 @@ }, "status": { "percent_control": 0 - }, - "terminal_id": "REDACTED" + } } diff --git a/tests/components/tuya/fixtures/cl_qqdxfdht.json b/tests/components/tuya/fixtures/cl_qqdxfdht.json index b8f568619db..c0a7bc1d0ba 100644 --- a/tests/components/tuya/fixtures/cl_qqdxfdht.json +++ b/tests/components/tuya/fixtures/cl_qqdxfdht.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfb9c4958fd06d141djpqa", "name": "bedroom blinds", "category": "cl", "product_id": "qqdxfdht", diff --git a/tests/components/tuya/fixtures/cl_zah67ekd.json b/tests/components/tuya/fixtures/cl_zah67ekd.json index 14d1c39fc94..b1920f1ecc5 100644 --- a/tests/components/tuya/fixtures/cl_zah67ekd.json +++ b/tests/components/tuya/fixtures/cl_zah67ekd.json @@ -1,5 +1,4 @@ { - "id": "zah67ekd", "name": "Kitchen Blinds", "category": "cl", "product_id": "zah67ekd", diff --git a/tests/components/tuya/fixtures/clkg_nhyj64w2.json b/tests/components/tuya/fixtures/clkg_nhyj64w2.json index 0f64bae778f..1aa6ebebd2c 100644 --- a/tests/components/tuya/fixtures/clkg_nhyj64w2.json +++ b/tests/components/tuya/fixtures/clkg_nhyj64w2.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf1fa053e0ba4e002c6we8", "name": "Tapparelle studio", "category": "clkg", "product_id": "nhyj64w2", diff --git a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json index fb544fb7d5e..c4657f30012 100644 --- a/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json +++ b/tests/components/tuya/fixtures/co2bj_yrr3eiyiacm31ski.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb14fd1dd93ca2ea34vpin", "name": "AQI", "category": "co2bj", "product_id": "yrr3eiyiacm31ski", diff --git a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json index 755b46fa397..2edd120cf8d 100644 --- a/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json +++ b/tests/components/tuya/fixtures/cs_ka2wfrdoogpvgzfi.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "mock_device_id", "name": "Dehumidifer", "category": "cs", "product_id": "ka2wfrdoogpvgzfi", diff --git a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json index 9b0b704e3de..b11dfe88582 100644 --- a/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json +++ b/tests/components/tuya/fixtures/cs_qhxmvae667uap4zh.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "28403630e8db84b7a963", "name": "DryFix", "category": "cs", "product_id": "qhxmvae667uap4zh", diff --git a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json index 27d4e825ab1..f4d01c2bc91 100644 --- a/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json +++ b/tests/components/tuya/fixtures/cs_vmxuxszzjwp5smli.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "mock_device_id", "name": "Dehumidifier ", "category": "cs", "product_id": "vmxuxszzjwp5smli", diff --git a/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json b/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json index 5574153a439..fbae30ad3eb 100644 --- a/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json +++ b/tests/components/tuya/fixtures/cs_zibqa9dutqyaxym2.json @@ -1,5 +1,4 @@ { - "id": "bf3fce6af592f12df3gbgq", "name": "Dehumidifier", "category": "cs", "product_id": "zibqa9dutqyaxym2", diff --git a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json index 84f76908338..a421a69bf08 100644 --- a/tests/components/tuya/fixtures/cwjwq_agwu93lr.json +++ b/tests/components/tuya/fixtures/cwjwq_agwu93lr.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf6574iutyikgwkx", "name": "Smart Odor Eliminator-Pro", "category": "cwjwq", "product_id": "agwu93lr", diff --git a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json index 4bdd6f3167d..e3858d37602 100644 --- a/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json +++ b/tests/components/tuya/fixtures/cwwsq_wfkzyy0evslzsmoi.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfd0273e59494eb34esvrx", "name": "Cleverio PF100", "category": "cwwsq", "product_id": "wfkzyy0evslzsmoi", diff --git a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json index 695da229041..6f9a8391726 100644 --- a/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json +++ b/tests/components/tuya/fixtures/cwysj_z3rpyvznfcch99aa.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "23536058083a8dc57d96", "name": "PIXI Smart Drinking Fountain", "category": "cwysj", "product_id": "z3rpyvznfcch99aa", diff --git a/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json index 760972e7fb0..8301c806a71 100644 --- a/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json +++ b/tests/components/tuya/fixtures/cz_0g1fmqh6d5io7lcn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "01155072c4dd573f92b8", "name": "Apollo light", "category": "cz", "product_id": "0g1fmqh6d5io7lcn", diff --git a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json index 27c3ae0c37f..c8191f8a023 100644 --- a/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json +++ b/tests/components/tuya/fixtures/cz_2jxesipczks0kdct.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb0c772dabbb19d653ssi5", "name": "HVAC Meter", "category": "cz", "product_id": "2jxesipczks0kdct", diff --git a/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json index f259ebd7d6c..8eaecf2407c 100644 --- a/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json +++ b/tests/components/tuya/fixtures/cz_cuhokdii7ojyw8k2.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "53703774d8f15ba9efd3", "name": "Buitenverlichting", "category": "cz", "product_id": "cuhokdii7ojyw8k2", diff --git a/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json index a92d2d370d0..77e19d69a0a 100644 --- a/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json +++ b/tests/components/tuya/fixtures/cz_dntgh2ngvshfxpsz.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf7a2cdaf3ce28d2f7uqnh", "name": "fakkel veranda ", "category": "cz", "product_id": "dntgh2ngvshfxpsz", diff --git a/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json index 0638bb02d1e..b40297eab8f 100644 --- a/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json +++ b/tests/components/tuya/fixtures/cz_hj0a5c7ckzzexu8l.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "051724052462ab286504", "name": "droger", "category": "cz", "product_id": "hj0a5c7ckzzexu8l", diff --git a/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json index b7f913a7153..04a2d12e853 100644 --- a/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json +++ b/tests/components/tuya/fixtures/cz_t0a4hwsf8anfsadp.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf4c0c538bfe408aa9gr2e", "name": "wallwasher front", "category": "cz", "product_id": "t0a4hwsf8anfsadp", diff --git a/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json index b3759178618..198a2462ad1 100644 --- a/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json +++ b/tests/components/tuya/fixtures/dc_l3bpgg8ibsagon4x.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfd9f45c6b882c9f46dxfc", "name": "LSC Party String Light RGBIC+CCT ", "category": "dc", "product_id": "l3bpgg8ibsagon4x", diff --git a/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json index 6cd0ca55379..8b6e491fa43 100644 --- a/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json +++ b/tests/components/tuya/fixtures/dj_8szt7whdvwpmxglk.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb10549aadfc74b7c8q2ti", "name": "Porch light E", "category": "dj", "product_id": "8szt7whdvwpmxglk", diff --git a/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json index ec8f6a0a4d5..d2e36e71f49 100644 --- a/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json +++ b/tests/components/tuya/fixtures/dj_8y0aquaa8v6tho8w.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf71858c3d27943679dsx9", "name": "dressoir spot", "category": "dj", "product_id": "8y0aquaa8v6tho8w", diff --git a/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json index 211c0bc12cf..86d1f8fd9d5 100644 --- a/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json +++ b/tests/components/tuya/fixtures/dj_baf9tt9lb8t5uc7z.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "40611462e09806c73134", "name": "Pokerlamp 2", "category": "dj", "product_id": "baf9tt9lb8t5uc7z", diff --git a/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json index 22650f7ae37..024501d59de 100644 --- a/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json +++ b/tests/components/tuya/fixtures/dj_d4g0fbsoaal841o6.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf671413db4cee1f9bqdcx", "name": "WC D1", "category": "dj", "product_id": "d4g0fbsoaal841o6", diff --git a/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json index 67df13c674f..d48e7228566 100644 --- a/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json +++ b/tests/components/tuya/fixtures/dj_djnozmdyqyriow8z.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf8885f3d18a73e395bfac", "name": "Fakkel 8", "category": "dj", "product_id": "djnozmdyqyriow8z", diff --git a/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json index 90cad22fd09..ae3a53e606e 100644 --- a/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json +++ b/tests/components/tuya/fixtures/dj_ekwolitfjhxn55js.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfb99bba00c9c90ba8gzgl", "name": "ab6", "category": "dj", "product_id": "ekwolitfjhxn55js", diff --git a/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json index 5b189b6a3e4..39cb6b78460 100644 --- a/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json +++ b/tests/components/tuya/fixtures/dj_fuupmcr2mb1odkja.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf0914a82b06ecf151xsf5", "name": "Slaapkamer", "category": "dj", "product_id": "fuupmcr2mb1odkja", diff --git a/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json index e8166a192dc..22e5eee1b6f 100644 --- a/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json +++ b/tests/components/tuya/fixtures/dj_hp6orhaqm6as3jnv.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "00450321483fda81c529", "name": "Master bedroom TV lights", "category": "dj", "product_id": "hp6orhaqm6as3jnv", diff --git a/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json index 893aafa3759..b7190caa78e 100644 --- a/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json +++ b/tests/components/tuya/fixtures/dj_hpc8ddyfv85haxa7.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "63362034840d8eb9029f", "name": "Garage", "category": "dj", "product_id": "hpc8ddyfv85haxa7", diff --git a/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json index f9062d9146d..a8cddb4ee4f 100644 --- a/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json +++ b/tests/components/tuya/fixtures/dj_iayz2jmtlipjnxj7.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf0fc1d7d4caa71a59us7c", "name": "LED Porch 2", "category": "dj", "product_id": "iayz2jmtlipjnxj7", diff --git a/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json index 295157d8370..299e8d573f1 100644 --- a/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json +++ b/tests/components/tuya/fixtures/dj_idnfq7xbx8qewyoa.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf599f5cffe1a5985depyk", "name": "AB1", "category": "dj", "product_id": "idnfq7xbx8qewyoa", diff --git a/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json index 1181b650f3e..affa875f3b4 100644 --- a/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json +++ b/tests/components/tuya/fixtures/dj_ilddqqih3tucdk68.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "84178216d8f15be52dc4", "name": "Ieskas", "category": "dj", "product_id": "ilddqqih3tucdk68", diff --git a/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json index d95179c921f..01c7e375002 100644 --- a/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json +++ b/tests/components/tuya/fixtures/dj_j1bgp31cffutizub.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfe49d7b6cd80536efdldi", "name": "Ceiling Portal", "category": "dj", "product_id": "j1bgp31cffutizub", diff --git a/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json index 93a802a7ee3..54c08ba7762 100644 --- a/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json +++ b/tests/components/tuya/fixtures/dj_lmnt3uyltk1xffrt.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "07608286600194e94248", "name": "DirectietKamer", "category": "dj", "product_id": "lmnt3uyltk1xffrt", diff --git a/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json b/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json index 49854adc889..daea124e8e0 100644 --- a/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json +++ b/tests/components/tuya/fixtures/dj_mki13ie507rlry4r.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "REDACTED", "name": "Garage light", "category": "dj", "product_id": "mki13ie507rlry4r", diff --git a/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json index bc919dd92d2..3cac3935c27 100644 --- a/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json +++ b/tests/components/tuya/fixtures/dj_nbumqpv8vz61enji.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf77c04cbd6a52a7be16ll", "name": "b2", "category": "dj", "product_id": "nbumqpv8vz61enji", diff --git a/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json index c519f1aa593..5fbea6fb287 100644 --- a/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json +++ b/tests/components/tuya/fixtures/dj_nlxvjzy1hoeiqsg6.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "40350105dc4f229a464e", "name": "hall 💡 ", "category": "dj", "product_id": "nlxvjzy1hoeiqsg6", diff --git a/tests/components/tuya/fixtures/dj_oe0cpnjg.json b/tests/components/tuya/fixtures/dj_oe0cpnjg.json index 646ce8a93d7..8c2a559a5c9 100644 --- a/tests/components/tuya/fixtures/dj_oe0cpnjg.json +++ b/tests/components/tuya/fixtures/dj_oe0cpnjg.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf8d8af3ddfe75b0195r0h", "name": "Front right Lighting trap", "category": "dj", "product_id": "oe0cpnjg", diff --git a/tests/components/tuya/fixtures/dj_riwp3k79.json b/tests/components/tuya/fixtures/dj_riwp3k79.json index f1a3579e660..bd4d013ab5b 100644 --- a/tests/components/tuya/fixtures/dj_riwp3k79.json +++ b/tests/components/tuya/fixtures/dj_riwp3k79.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf46b2b81ca41ce0c1xpsw", "name": "LED KEUKEN 2", "category": "dj", "product_id": "riwp3k79", diff --git a/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json index 20c91ad7739..91c4dff5a42 100644 --- a/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json +++ b/tests/components/tuya/fixtures/dj_tmsloaroqavbucgn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf252b8ee16b2e78bdoxlp", "name": "Pokerlamp 1", "category": "dj", "product_id": "tmsloaroqavbucgn", diff --git a/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json index 7ea5905411d..4b7a3a4e879 100644 --- a/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json +++ b/tests/components/tuya/fixtures/dj_ufq2xwuzd4nb0qdr.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf8edbd51a52c01a4bfgqf", "name": "Sjiethoes", "category": "dj", "product_id": "ufq2xwuzd4nb0qdr", diff --git a/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json index 4d6749ea0b4..9aa3646a11b 100644 --- a/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json +++ b/tests/components/tuya/fixtures/dj_vqwcnabamzrc2kab.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfd56f4718874ee8830xdw", "name": "Strip 2", "category": "dj", "product_id": "vqwcnabamzrc2kab", diff --git a/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json index cce66d90b0c..2e339c64678 100644 --- a/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json +++ b/tests/components/tuya/fixtures/dj_xokdfs6kh5ednakk.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfc1ef4da4accc0731oggw", "name": "ERKER 1-Gold ", "category": "dj", "product_id": "xokdfs6kh5ednakk", diff --git a/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json index d1c23663144..2a6b4f34ce7 100644 --- a/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json +++ b/tests/components/tuya/fixtures/dj_zakhnlpdiu0ycdxn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "03010850c44f33966362", "name": "Stoel", "category": "dj", "product_id": "zakhnlpdiu0ycdxn", diff --git a/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json index 624f7fb4347..0ae793b3d1b 100644 --- a/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json +++ b/tests/components/tuya/fixtures/dj_zav1pa32pyxray78.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "500425642462ab50909b", "name": "Gengske 💡 ", "category": "dj", "product_id": "zav1pa32pyxray78", diff --git a/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json index cede2b65682..b500c67d0ea 100644 --- a/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json +++ b/tests/components/tuya/fixtures/dj_zputiamzanuk6yky.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf74164049de868395pbci", "name": "Floodlight", "category": "dj", "product_id": "zputiamzanuk6yky", diff --git a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json index 2652399bdcb..eaec5aed56c 100644 --- a/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json +++ b/tests/components/tuya/fixtures/dlq_kxdr6su0c55p7bbo.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": null, "disabled_by": null, "disabled_polling": false, - "id": "bf5e5bde2c52cb5994cd27", "name": "Metering_3PN_WiFi_stable", "category": "dlq", "product_id": "kxdr6su0c55p7bbo", diff --git a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json index 3aae03c904a..9a82643e2f9 100644 --- a/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json +++ b/tests/components/tuya/fixtures/fs_g0ewlb1vmwqljzji.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "XXX", "name": "Ceiling Fan With Light", "category": "fs", "product_id": "g0ewlb1vmwqljzji", diff --git a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json index 02b3808f84d..e8c59f50d7f 100644 --- a/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json +++ b/tests/components/tuya/fixtures/fs_ibytpo6fpnugft1c.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "10706550a4e57c88b93a", "name": "Ventilador Cama", "category": "fs", "product_id": "ibytpo6fpnugft1c", diff --git a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json index ddfbce3ae11..62723670973 100644 --- a/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json +++ b/tests/components/tuya/fixtures/gyd_lgekqfxdabipm3tn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "eb3e988f33c233290cfs3l", "name": "Colorful PIR Night Light", "category": "gyd", "product_id": "lgekqfxdabipm3tn", diff --git a/tests/components/tuya/fixtures/hps_2aaelwxk.json b/tests/components/tuya/fixtures/hps_2aaelwxk.json index 4e5066e77f4..77c4ad47839 100644 --- a/tests/components/tuya/fixtures/hps_2aaelwxk.json +++ b/tests/components/tuya/fixtures/hps_2aaelwxk.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf78687ad321a3aeb8a73m", "name": "Human presence Office", "category": "hps", "product_id": "2aaelwxk", diff --git a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json index a190161953b..a61ebc52659 100644 --- a/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json +++ b/tests/components/tuya/fixtures/kg_gbm9ata1zrzaez4a.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": true, - "id": "0665305284f3ebe9fdc1", "name": "QT-Switch", "category": "kg", "product_id": "gbm9ata1zrzaez4a", diff --git a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json index 5758fce2152..4e148140624 100644 --- a/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json +++ b/tests/components/tuya/fixtures/kj_CAjWAxBUZt7QZHfz.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "152027113c6105cce49c", "name": "HL400", "category": "kj", "product_id": "CAjWAxBUZt7QZHfz", diff --git a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json index 642ef968608..45015bff0ac 100644 --- a/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json +++ b/tests/components/tuya/fixtures/kj_yrzylxax1qspdgpp.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "CENSORED", "name": "Bree", "category": "kj", "product_id": "yrzylxax1qspdgpp", diff --git a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json index cb158a967b4..b36064724af 100644 --- a/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json +++ b/tests/components/tuya/fixtures/ks_j9fa8ahzac8uvlfl.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "mock_device_id", "name": "Tower Fan CA-407G Smart", "category": "ks", "product_id": "j9fa8ahzac8uvlfl", diff --git a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json index 5b29fd0a191..3dd9c3713dc 100644 --- a/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json +++ b/tests/components/tuya/fixtures/kt_5wnlzekkstwcdsvm.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "mock_device_id", "name": "Air Conditioner", "category": "kt", "product_id": "5wnlzekkstwcdsvm", diff --git a/tests/components/tuya/fixtures/ldcg_9kbbfeho.json b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json index 223e39a00d4..6281085a06c 100644 --- a/tests/components/tuya/fixtures/ldcg_9kbbfeho.json +++ b/tests/components/tuya/fixtures/ldcg_9kbbfeho.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfbc8a692eaeeef455fkct", "name": "Luminosité", "category": "ldcg", "product_id": "9kbbfeho", diff --git a/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json b/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json index 1a25a84ec2c..ee69a811a92 100644 --- a/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json +++ b/tests/components/tuya/fixtures/mal_gyitctrjj1kefxp2.json @@ -1,5 +1,4 @@ { - "id": "123123aba12312312dazub", "name": "Multifunction alarm", "category": "mal", "product_id": "gyitctrjj1kefxp2", diff --git a/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json b/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json index c73b6c34878..0e0a947aff7 100644 --- a/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json +++ b/tests/components/tuya/fixtures/mcs_7jIGJAymiH8OsFFb.json @@ -6,7 +6,6 @@ "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf5cccf9027080e2dbb9w3", "name": "Door Garage ", "model": "", "category": "mcs", diff --git a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json index 402e73c732b..df6375a6827 100644 --- a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json +++ b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bff434eca843ffc9afmthv", "name": "Sous Vide", "category": "mzj", "product_id": "qavcakohisj5adyh", diff --git a/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json index 4ed7ecf0373..aa16d5a91d8 100644 --- a/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json +++ b/tests/components/tuya/fixtures/pc_t2afic7i3v1bwhfp.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf2206da15147500969d6e", "name": "Bubbelbad", "category": "pc", "product_id": "t2afic7i3v1bwhfp", diff --git a/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json index 99929616ec7..ddff6df21a1 100644 --- a/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json +++ b/tests/components/tuya/fixtures/pc_trjopo1vdlt9q1tg.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "15727703c4dd5709cd78", "name": "Terras", "category": "pc", "product_id": "trjopo1vdlt9q1tg", diff --git a/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json index 98843da5614..6e68b1a92db 100644 --- a/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json +++ b/tests/components/tuya/fixtures/pir_3amxzozho9xp4mkh.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "73486068483fda10d633", "name": "rat trap hedge", "category": "pir", "product_id": "3amxzozho9xp4mkh", diff --git a/tests/components/tuya/fixtures/pir_fcdjzz3s.json b/tests/components/tuya/fixtures/pir_fcdjzz3s.json index 65740a4106c..74f223ee7ea 100644 --- a/tests/components/tuya/fixtures/pir_fcdjzz3s.json +++ b/tests/components/tuya/fixtures/pir_fcdjzz3s.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf445324326cbde7c5rg7b", "name": "Motion sensor lidl zigbee", "category": "pir", "product_id": "fcdjzz3s", diff --git a/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json index e4122ee5f9d..8bf85a1d339 100644 --- a/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json +++ b/tests/components/tuya/fixtures/pir_wqz93nrdomectyoz.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "20401777500291cfe3a2", "name": "PIR outside stairs", "category": "pir", "product_id": "wqz93nrdomectyoz", diff --git a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json index 6cae732aedf..97c4a21526c 100644 --- a/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json +++ b/tests/components/tuya/fixtures/qccdz_7bvgooyjhiua1yyq.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf83514d9c14b426f0fz5y", "name": "AC charging control box", "category": "qccdz", "product_id": "7bvgooyjhiua1yyq", diff --git a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json index c538630c542..549e23cc914 100644 --- a/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json +++ b/tests/components/tuya/fixtures/qxj_fsea1lat3vuktbt6.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf84c743a84eb2c8abeurz", "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", "category": "qxj", "product_id": "fsea1lat3vuktbt6", diff --git a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json index efffe12a2f9..93b3aa580a0 100644 --- a/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json +++ b/tests/components/tuya/fixtures/qxj_is2indt9nlth6esa.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bff00f6abe0563b284t77p", "name": "Frysen", "category": "qxj", "product_id": "is2indt9nlth6esa", diff --git a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json index 24b4dbda594..6516626d789 100644 --- a/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json +++ b/tests/components/tuya/fixtures/rqbj_4iqe2hsfyd86kwwc.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaus.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "ebb9d0eb5014f98cfboxbz", "name": "Gas sensor", "category": "rqbj", "product_id": "4iqe2hsfyd86kwwc", diff --git a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json index 77d94cb951b..ba461a6226d 100644 --- a/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json +++ b/tests/components/tuya/fixtures/sd_lr33znaodtyarrrz.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfa951ca98fcf64fddqlmt", "name": "V20", "category": "sd", "product_id": "lr33znaodtyarrrz", diff --git a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json index e57e9274690..30eff8b5c8b 100644 --- a/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json +++ b/tests/components/tuya/fixtures/sfkzq_o6dagifntoafakst.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfb9bfc18eeaed2d85yt5m", "name": "Sprinkler Cesare", "category": "sfkzq", "product_id": "o6dagifntoafakst", diff --git a/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json index a3068983c87..b0fd9d38bdf 100644 --- a/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json +++ b/tests/components/tuya/fixtures/sgbj_ulv4nnue7gqp0rjk.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf0984adfeffe10d5a3ofd", "name": "Siren veranda ", "category": "sgbj", "product_id": "ulv4nnue7gqp0rjk", diff --git a/tests/components/tuya/fixtures/sj_tgvtvdoc.json b/tests/components/tuya/fixtures/sj_tgvtvdoc.json index a63fd7af508..bba2d80da88 100644 --- a/tests/components/tuya/fixtures/sj_tgvtvdoc.json +++ b/tests/components/tuya/fixtures/sj_tgvtvdoc.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf58e095fd2d86d592tveh", "name": "Tournesol", "category": "sj", "product_id": "tgvtvdoc", diff --git a/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json index a6543eac5ea..ed30e930e2b 100644 --- a/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json +++ b/tests/components/tuya/fixtures/sp_drezasavompxpcgm.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf7b8e59f8cd49f425mmfm", "name": "CAM GARAGE", "category": "sp", "product_id": "drezasavompxpcgm", diff --git a/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json index 9a7bb9f1eca..6825c67efc2 100644 --- a/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json +++ b/tests/components/tuya/fixtures/sp_rjKXWRohlvOTyLBu.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf9d5b7ea61ea4c9a6rom9", "name": "CAM PORCH", "category": "sp", "product_id": "rjKXWRohlvOTyLBu", diff --git a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json index 7e4705650b1..e98e38b21c8 100644 --- a/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json +++ b/tests/components/tuya/fixtures/sp_sdd5f5f2dl5wydjf.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf3f8b448bbc123e29oghf", "name": "C9", "category": "sp", "product_id": "sdd5f5f2dl5wydjf", diff --git a/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json index fdfbae9fbbf..94a8a7da26f 100644 --- a/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json +++ b/tests/components/tuya/fixtures/tdq_1aegphq4yfd50e6b.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfa008a4f82a56616c69uz", "name": "jardin Fraises", "category": "tdq", "product_id": "1aegphq4yfd50e6b", diff --git a/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json index e3476118f20..3d7b24df7ec 100644 --- a/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json +++ b/tests/components/tuya/fixtures/tdq_9htyiowaf5rtdhrv.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bff35871a2f4430058vs8u", "name": "Framboisiers", "category": "tdq", "product_id": "9htyiowaf5rtdhrv", diff --git a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json index e7c79f3fb41..844f8cd3742 100644 --- a/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json +++ b/tests/components/tuya/fixtures/tdq_cq1p0nt0a4rixnex.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf082711d275c0c883vb4p", "name": "4-433", "category": "tdq", "product_id": "cq1p0nt0a4rixnex", diff --git a/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json index 1e40823b93d..e1f0865658f 100644 --- a/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json +++ b/tests/components/tuya/fixtures/tdq_nockvv2k39vbrxxk.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyain.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "d7ca553b5f406266350poc", "name": "Seating side 6-ch Smart Switch ", "category": "tdq", "product_id": "nockvv2k39vbrxxk", diff --git a/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json index da26a133014..cc8d186513c 100644 --- a/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json +++ b/tests/components/tuya/fixtures/tdq_pu8uhxhwcp3tgoz7.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf0dc19ab84dc3627ep2un", "name": "Socket3", "category": "tdq", "product_id": "pu8uhxhwcp3tgoz7", diff --git a/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json index 708764184ad..54a8d78d92d 100644 --- a/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json +++ b/tests/components/tuya/fixtures/tdq_uoa3mayicscacseb.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfb3c90d87dac93d2bdxn3", "name": "Living room left", "category": "tdq", "product_id": "uoa3mayicscacseb", diff --git a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json index 656c626c4fe..ce8ab6c1d63 100644 --- a/tests/components/tuya/fixtures/tyndj_pyakuuoc.json +++ b/tests/components/tuya/fixtures/tyndj_pyakuuoc.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfdb773e4ae317e3915h2i", "name": "Solar zijpad", "category": "tyndj", "product_id": "pyakuuoc", diff --git a/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json index 2fa798b2f60..7fedfb4826e 100644 --- a/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json +++ b/tests/components/tuya/fixtures/wfcon_b25mh8sxawsgndck.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf63312cdd4722555bmsuv", "name": "ZigBee Gateway", "category": "wfcon", "product_id": "b25mh8sxawsgndck", diff --git a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json index 0e39f713dd0..2fb4e9a6064 100644 --- a/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json +++ b/tests/components/tuya/fixtures/wg2_nwxr8qcu4seltoro.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "1752690839034sq255y", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf79ca977d67322eb2o68m", "name": "X5 Zigbee Gateway", "category": "wg2", "product_id": "nwxr8qcu4seltoro", diff --git a/tests/components/tuya/fixtures/wk_6kijc7nd.json b/tests/components/tuya/fixtures/wk_6kijc7nd.json index 068a9b676a7..552de66c1d9 100644 --- a/tests/components/tuya/fixtures/wk_6kijc7nd.json +++ b/tests/components/tuya/fixtures/wk_6kijc7nd.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf3c2c83660b8e19e152jb", "name": "Кабінет", "category": "wk", "product_id": "6kijc7nd", diff --git a/tests/components/tuya/fixtures/wk_aqoouq7x.json b/tests/components/tuya/fixtures/wk_aqoouq7x.json index 900ae356f38..3bf17e356ff 100644 --- a/tests/components/tuya/fixtures/wk_aqoouq7x.json +++ b/tests/components/tuya/fixtures/wk_aqoouq7x.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf6fc1645146455a2efrex", "name": "Clima cucina", "category": "wk", "product_id": "aqoouq7x", diff --git a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json index 002b0609464..f7c28db1043 100644 --- a/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json +++ b/tests/components/tuya/fixtures/wk_fi6dne5tu4t1nm6j.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfb45cb8a9452fba66lexg", "name": "WiFi Smart Gas Boiler Thermostat ", "category": "wk", "product_id": "fi6dne5tu4t1nm6j", diff --git a/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json index bac85a54ed2..0841f77ca2c 100644 --- a/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json +++ b/tests/components/tuya/fixtures/wk_gogb05wrtredz3bs.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf1085bf049a74fcc1idy2", "name": "smart thermostats", "category": "wk", "product_id": "gogb05wrtredz3bs", diff --git a/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json index 352a0ded392..efe02c633f3 100644 --- a/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json +++ b/tests/components/tuya/fixtures/wk_y5obtqhuztqsf2mj.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf8d64588f4a61965ezszs", "name": "Term - Prizemi", "category": "wk", "product_id": "y5obtqhuztqsf2mj", diff --git a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json index 2929872f4c1..51367039d9f 100644 --- a/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json +++ b/tests/components/tuya/fixtures/wsdcg_g2y6z3p3ja2qhyav.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf316b8707b061f044th18", "name": "NP DownStairs North", "category": "wsdcg", "product_id": "g2y6z3p3ja2qhyav", diff --git a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json index a7ab15a4511..5fd511c7506 100644 --- a/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json +++ b/tests/components/tuya/fixtures/ydkt_jevroj5aguwdbs2e.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "mock_device_id", "name": "DOLCECLIMA 10 HP WIFI", "category": "ydkt", "product_id": "jevroj5aguwdbs2e", diff --git a/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json index f80b0cd5cd1..c39835694c7 100644 --- a/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json +++ b/tests/components/tuya/fixtures/ywbj_gf9dejhmzffgdyfj.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "8670375210521cf1349c", "name": " Smoke detector upstairs ", "category": "ywbj", "product_id": "gf9dejhmzffgdyfj", diff --git a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json index 8b1cff0c773..31d26fbb715 100644 --- a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json +++ b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf3d16d38b17d7034ddxd4", "name": "Rainwater Tank Level", "category": "ywcgq", "product_id": "h8lvyoahr6s6aybf", diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json index 52eda664345..200790afedb 100644 --- a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -135,6 +135,5 @@ "installation_height": 560, "liquid_depth_max": 100, "liquid_level_percent": 100 - }, - "terminal_id": "REDACTED" + } } diff --git a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json index dc1d2143087..92f507abaca 100644 --- a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json +++ b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyacn.com", - "terminal_id": "1753864737914eTkTk2", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "6c0887b46a2eaf56e0ui7d", "name": "XOCA-DAC212XC V2-S1", "category": "zndb", "product_id": "4ggkyflayu1h1ho9", diff --git a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json index 797ddba3587..caf9074d277 100644 --- a/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json +++ b/tests/components/tuya/fixtures/zndb_ze8faryrxr0glqnn.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bfe33b4c74661f1f1bgacy", "name": "Meter", "category": "zndb", "product_id": "ze8faryrxr0glqnn", diff --git a/tests/components/tuya/fixtures/zwjcy_myd45weu.json b/tests/components/tuya/fixtures/zwjcy_myd45weu.json index 3ea111abb0e..dc6c0510ffc 100644 --- a/tests/components/tuya/fixtures/zwjcy_myd45weu.json +++ b/tests/components/tuya/fixtures/zwjcy_myd45weu.json @@ -1,10 +1,8 @@ { "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf1a0431555359ce06ie0z", "name": "Patates", "category": "zwjcy", "product_id": "myd45weu", diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index ab96f58ecd0..c0311198fdd 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -7,12 +7,13 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import initialize_entry +from . import DEVICE_MOCKS, initialize_entry -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.parametrize("mock_device_code", ["ydkt_jevroj5aguwdbs2e"]) @@ -38,3 +39,21 @@ async def test_unsupported_device( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +async def test_fixtures_valid(hass: HomeAssistant) -> None: + """Ensure Tuya fixture files are valid.""" + # We want to ensure that the fixture files do not contain + # `home_assistant`, `id`, or `terminal_id` keys. + # These are provided by the Tuya diagnostics and should be removed + # from the fixture. + EXCLUDE_KEYS = ("home_assistant", "id", "terminal_id") + + for device_code in DEVICE_MOCKS: + details = await async_load_json_object_fixture( + hass, f"{device_code}.json", DOMAIN + ) + for key in EXCLUDE_KEYS: + assert key not in details, ( + f"Please remove data[`'{key}']` from {device_code}.json" + ) From 0f3f8d5707dcb87c71337e335dbe6e8ae2cca29c Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 8 Aug 2025 14:57:12 +0300 Subject: [PATCH 1015/1118] Bump openai to 1.99.3 (#150232) --- homeassistant/components/open_router/entity.py | 15 ++++++++------- .../components/open_router/manifest.json | 2 +- .../components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/open_router/test_conversation.py | 6 +++--- tests/components/openai_conversation/__init__.py | 2 ++ .../openai_conversation/test_conversation.py | 1 + tests/components/openai_conversation/test_init.py | 3 +++ 9 files changed, 21 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index ac01ec89704..aa74442f7f4 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -9,15 +9,15 @@ from typing import TYPE_CHECKING, Any, Literal import openai from openai.types.chat import ( ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, - ChatCompletionToolParam, ChatCompletionUserMessageParam, ) -from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.chat.chat_completion_message_function_tool_call_param import Function from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema from openai.types.shared_params.response_format_json_schema import JSONSchema import voluptuous as vol @@ -84,7 +84,7 @@ def _format_structured_output( def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None, -) -> ChatCompletionToolParam: +) -> ChatCompletionFunctionToolParam: """Format tool specification.""" tool_spec = FunctionDefinition( name=tool.name, @@ -92,7 +92,7 @@ def _format_tool( ) if tool.description: tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) def _convert_content_to_chat_message( @@ -121,7 +121,7 @@ def _convert_content_to_chat_message( ) if isinstance(content, conversation.AssistantContent) and content.tool_calls: param["tool_calls"] = [ - ChatCompletionMessageToolCallParam( + ChatCompletionMessageFunctionToolCallParam( type="function", id=tool_call.id, function=Function( @@ -160,6 +160,7 @@ async def _transform_response( tool_args=_decode_tool_arguments(tool_call.function.arguments), ) for tool_call in message.tool_calls + if tool_call.type == "function" ] yield data @@ -199,7 +200,7 @@ class OpenRouterEntity(Entity): "extra_body": {"require_parameters": True}, } - tools: list[ChatCompletionToolParam] | None = None + tools: list[ChatCompletionFunctionToolParam] | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 8f989e63189..7dd824c2587 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.93.3", "python-open-router==0.3.1"] + "requirements": ["openai==1.99.3", "python-open-router==0.3.1"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 5a6d76a396b..12ee6278c5d 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.93.3"] + "requirements": ["openai==1.99.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c29a2c81b08..8e644cc2f41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1601,7 +1601,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.93.3 +openai==1.99.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc27d14f1ad..5aa75e48096 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1369,7 +1369,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.93.3 +openai==1.99.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index afbdd907f93..edd47572120 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -7,10 +7,10 @@ from openai.types import CompletionUsage from openai.types.chat import ( ChatCompletion, ChatCompletionMessage, - ChatCompletionMessageToolCall, + ChatCompletionMessageFunctionToolCall, ) from openai.types.chat.chat_completion import Choice -from openai.types.chat.chat_completion_message_tool_call import Function +from openai.types.chat.chat_completion_message_function_tool_call_param import Function import pytest from syrupy.assertion import SnapshotAssertion @@ -133,7 +133,7 @@ async def test_function_call( role="assistant", function_call=None, tool_calls=[ - ChatCompletionMessageToolCall( + ChatCompletionMessageFunctionToolCall( id="call_call_1", function=Function( arguments='{"param1":"call1"}', diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index c10c23df237..0cdccb6d0cf 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -65,6 +65,7 @@ def create_message_item( content_index=0, delta=delta, item_id=id, + logprobs=[], output_index=output_index, sequence_number=0, type="response.output_text.delta", @@ -77,6 +78,7 @@ def create_message_item( ResponseTextDoneEvent( content_index=0, item_id=id, + logprobs=[], output_index=output_index, text="".join(text), sequence_number=0, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index dafcba7bfeb..5abce689855 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -289,6 +289,7 @@ async def test_function_call( ) assert mock_create_stream.call_args.kwargs["input"][2] == { + "content": None, "id": "rs_A", "summary": [], "type": "reasoning", diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index fb8be3b2e68..66afc41826b 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -94,6 +94,7 @@ async def test_generate_image_service( with patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, return_value=ImagesResponse( created=1700000000, data=[ @@ -130,6 +131,7 @@ async def test_generate_image_service_error( with ( patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, side_effect=RateLimitError( response=httpx.Response( status_code=500, request=httpx.Request(method="GET", url="") @@ -154,6 +156,7 @@ async def test_generate_image_service_error( with ( patch( "openai.resources.images.AsyncImages.generate", + new_callable=AsyncMock, return_value=ImagesResponse( created=1700000000, data=[ From 8aee05b8b0d444dee888898e24028160b53b20a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:57:20 +0200 Subject: [PATCH 1016/1118] Bump github/codeql-action from 3.29.5 to 3.29.7 (#150254) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c5dcf19ce6e..f9795c219c5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.5 + uses: github/codeql-action/init@v3.29.7 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.5 + uses: github/codeql-action/analyze@v3.29.7 with: category: "/language:python" From ef4f476844daac46313cd404a032bead5ba09511 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:26:04 +0100 Subject: [PATCH 1017/1118] Fix handing for zero volume error in Squeezebox (#150265) --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 49aad4fd698..839e419dd96 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -325,7 +325,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._player.volume: + if self._player.volume is not None: return int(float(self._player.volume)) / 100.0 return None From 01c197e830eb535e05b9d41cd1ddf99ac1ab2767 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Aug 2025 15:06:31 +0200 Subject: [PATCH 1018/1118] Constraint num2words to 0.5.14 (#150276) --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1a2f0d182a4..b79b0ecf6be 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -212,3 +212,6 @@ multidict>=6.4.2 # rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 rpds-py==0.26.0 + +# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI +num2words==0.5.14 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b13f586439d..a62f2f62bc1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -238,6 +238,9 @@ multidict>=6.4.2 # rpds-py frequently updates cargo causing build failures # No wheels upstream available for armhf & armv7 rpds-py==0.26.0 + +# Constraint num2words to 0.5.14 as 0.5.15 and 0.5.16 were removed from PyPI +num2words==0.5.14 """ GENERATED_MESSAGE = ( From a8779d5f52a6da361c56546bab5134a97761a3e9 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:24:41 +0100 Subject: [PATCH 1019/1118] Fix error on startup when no Apps or Radio plugins are installed for Squeezebox (#150267) --- .../components/squeezebox/browse_media.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 4f2a1fa7aa5..e14f1989cbe 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -157,26 +157,28 @@ class BrowseData: cmd = ["apps", 0, browse_limit] result = await player.async_query(*cmd) - for app in result["appss_loop"]: - app_cmd = "app-" + app["cmd"] - if app_cmd not in self.known_apps_radios: - self.add_new_command(app_cmd, "item_id") - _LOGGER.debug( - "Adding new command %s to browse data for player %s", - app_cmd, - player.player_id, - ) + if result["appss_loop"]: + for app in result["appss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) cmd = ["radios", 0, browse_limit] result = await player.async_query(*cmd) - for app in result["radioss_loop"]: - app_cmd = "app-" + app["cmd"] - if app_cmd not in self.known_apps_radios: - self.add_new_command(app_cmd, "item_id") - _LOGGER.debug( - "Adding new command %s to browse data for player %s", - app_cmd, - player.player_id, - ) + if result["radioss_loop"]: + for app in result["radioss_loop"]: + app_cmd = "app-" + app["cmd"] + if app_cmd not in self.known_apps_radios: + self.add_new_command(app_cmd, "item_id") + _LOGGER.debug( + "Adding new command %s to browse data for player %s", + app_cmd, + player.player_id, + ) def _build_response_apps_radios_category( From 23a2d69984342b3f3a91bd33fc0d0a6575a3af40 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:25:19 +0200 Subject: [PATCH 1020/1118] Volvo: fix missing charging power options (#150272) --- homeassistant/components/volvo/sensor.py | 7 +++- homeassistant/components/volvo/strings.json | 4 +- .../fixtures/ex30_2024/energy_state.json | 41 +++++++++---------- .../volvo/snapshots/test_sensor.ambr | 24 +++++++---- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index dd982238a47..647c7b578e8 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -87,7 +87,12 @@ def _charging_power_status_value(field: VolvoCarsValue) -> str | None: return None -_CHARGING_POWER_STATUS_OPTIONS = ["providing_power", "no_power_available"] +_CHARGING_POWER_STATUS_OPTIONS = [ + "fault", + "power_available_but_not_activated", + "providing_power", + "no_power_available", +] _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( # command-accessibility endpoint diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index 4fe7429117c..c429c106574 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -94,7 +94,7 @@ "state": { "connected": "[%key:common::state::connected%]", "disconnected": "[%key:common::state::disconnected%]", - "fault": "[%key:common::state::error%]" + "fault": "[%key:common::state::fault%]" } }, "charging_current_limit": { @@ -106,6 +106,8 @@ "charging_power_status": { "name": "Charging power status", "state": { + "fault": "[%key:common::state::fault%]", + "power_available_but_not_activated": "Power available", "providing_power": "Providing power", "no_power_available": "No power" } diff --git a/tests/components/volvo/fixtures/ex30_2024/energy_state.json b/tests/components/volvo/fixtures/ex30_2024/energy_state.json index fe42dba568a..0170d1aa617 100644 --- a/tests/components/volvo/fixtures/ex30_2024/energy_state.json +++ b/tests/components/volvo/fixtures/ex30_2024/energy_state.json @@ -1,57 +1,56 @@ { "batteryChargeLevel": { "status": "OK", - "value": 38, + "value": 90.0, "unit": "percentage", - "updatedAt": "2025-07-02T08:51:23Z" + "updatedAt": "2025-08-07T14:30:32Z" }, "electricRange": { "status": "OK", - "value": 90, + "value": 327, "unit": "km", - "updatedAt": "2025-07-02T08:51:23Z" + "updatedAt": "2025-08-07T14:30:32Z" }, "chargerConnectionStatus": { "status": "OK", - "value": "DISCONNECTED", - "updatedAt": "2025-07-02T08:51:23Z" + "value": "CONNECTED", + "updatedAt": "2025-08-07T14:30:32Z" }, "chargingStatus": { "status": "OK", - "value": "IDLE", - "updatedAt": "2025-07-02T08:51:23Z" + "value": "DONE", + "updatedAt": "2025-08-07T14:30:32Z" }, "chargingType": { "status": "OK", - "value": "NONE", - "updatedAt": "2025-07-02T08:51:23Z" + "value": "AC", + "updatedAt": "2025-08-07T14:30:32Z" }, "chargerPowerStatus": { "status": "OK", - "value": "NO_POWER_AVAILABLE", - "updatedAt": "2025-07-02T08:51:23Z" + "value": "FAULT", + "updatedAt": "2025-08-07T14:30:32Z" }, "estimatedChargingTimeToTargetBatteryChargeLevel": { "status": "OK", - "value": 0, + "value": 2, "unit": "minutes", - "updatedAt": "2025-07-02T08:51:23Z" + "updatedAt": "2025-08-07T14:30:32Z" }, "chargingCurrentLimit": { - "status": "OK", - "value": 32, - "unit": "ampere", - "updatedAt": "2024-03-05T08:38:44Z" + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" }, "targetBatteryChargeLevel": { "status": "OK", "value": 90, "unit": "percentage", - "updatedAt": "2024-09-22T09:40:12Z" + "updatedAt": "2025-08-07T14:49:50Z" }, "chargingPower": { "status": "ERROR", - "code": "PROPERTY_NOT_FOUND", - "message": "No valid value could be found for the requested property" + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" } } diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index d5346cf9cd8..b651bbd526f 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -52,7 +52,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '38', + 'state': '90.0', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_battery_capacity-entry] @@ -229,7 +229,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'disconnected', + 'state': 'connected', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-entry] @@ -285,7 +285,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '32', + 'state': 'unavailable', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-entry] @@ -351,6 +351,8 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'fault', + 'power_available_but_not_activated', 'providing_power', 'no_power_available', ]), @@ -390,6 +392,8 @@ 'device_class': 'enum', 'friendly_name': 'Volvo EX30 Charging power status', 'options': list([ + 'fault', + 'power_available_but_not_activated', 'providing_power', 'no_power_available', ]), @@ -399,7 +403,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'no_power_available', + 'state': 'fault', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_status-entry] @@ -465,7 +469,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'idle', + 'state': 'done', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_type-entry] @@ -525,7 +529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'none', + 'state': 'ac', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_empty_battery-entry] @@ -581,7 +585,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '90', + 'state': '327', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-entry] @@ -693,7 +697,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '2', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_odometer-entry] @@ -2276,6 +2280,8 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ + 'fault', + 'power_available_but_not_activated', 'providing_power', 'no_power_available', ]), @@ -2315,6 +2321,8 @@ 'device_class': 'enum', 'friendly_name': 'Volvo XC40 Charging power status', 'options': list([ + 'fault', + 'power_available_but_not_activated', 'providing_power', 'no_power_available', ]), From c0155f5e809342a77f667c925a53b62356e0dc63 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:26:02 +0200 Subject: [PATCH 1021/1118] Handle Unifi Protect BadRequest exception during API key creation (#150223) --- .../components/unifiprotect/__init__.py | 4 +-- tests/components/unifiprotect/test_init.py | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 5fa9a85d341..97a5ca67186 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -8,7 +8,7 @@ import logging from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Bootstrap -from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.exceptions import BadRequest, ClientError, NotAuthorized # Import the test_util.anonymize module from the uiprotect package # in __init__ to ensure it gets imported in the executor since the @@ -100,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: new_api_key = await protect.create_api_key( name=f"Home Assistant ({hass.config.location_name})" ) - except NotAuthorized as err: + except (NotAuthorized, BadRequest) as err: _LOGGER.error("Failed to create API key: %s", err) else: protect.set_api_key(new_api_key) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index b951d95fbdc..0776feece54 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -5,9 +5,10 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect import NvrError, ProtectApiClient from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import NVR, Bootstrap, CloudAccount, Light +from uiprotect.exceptions import BadRequest, NotAuthorized from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, @@ -414,6 +415,28 @@ async def test_setup_handles_api_key_creation_failure( ufp.api.set_api_key.assert_not_called() +@pytest.mark.parametrize("mock_user_can_write_nvr", [True], indirect=True) +async def test_setup_handles_api_key_creation_bad_request( + hass: HomeAssistant, ufp: MockUFPFixture, mock_user_can_write_nvr: Mock +) -> None: + """Test handling of API key creation BadRequest error.""" + # Setup: API key is not set, user has write permissions, but creation fails with BadRequest + ufp.api.is_api_key_set.return_value = False + ufp.api.create_api_key = AsyncMock( + side_effect=BadRequest("Invalid API key creation request") + ) + + # Should fail with auth error due to API key creation failure + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert ufp.entry.state is ConfigEntryState.SETUP_ERROR + + # Verify API key creation was attempted but set_api_key was not called + ufp.api.create_api_key.assert_called_once_with(name="Home Assistant (test home)") + ufp.api.set_api_key.assert_not_called() + + async def test_setup_with_existing_api_key( hass: HomeAssistant, ufp: MockUFPFixture ) -> None: From 2d720f0d32c6d58e29cb2b60ba1bb0e06d57598e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:27:00 -0400 Subject: [PATCH 1022/1118] Fix JSON serialization for ZHA diagnostics download (#150210) --- homeassistant/components/zha/diagnostics.py | 16 +++++++++++++++- .../zha/snapshots/test_diagnostics.ambr | 1 + tests/components/zha/test_diagnostics.py | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 6c5fcba1f8b..4383aa52afa 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -8,6 +8,7 @@ from typing import Any from zha.application.const import ATTR_IEEE from zha.application.gateway import Gateway +from zigpy.application import ControllerApplication from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.types import Channels @@ -63,6 +64,19 @@ def shallow_asdict(obj: Any) -> dict: return obj +def get_application_state_diagnostics(app: ControllerApplication) -> dict: + """Dump the application state as a dictionary.""" + data = shallow_asdict(app.state) + + # EUI64 objects in zigpy are not subclasses of any JSON-serializable key type and + # must be converted to strings. + data["network_info"]["nwk_addresses"] = { + str(k): v for k, v in data["network_info"]["nwk_addresses"].items() + } + + return data + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: @@ -79,7 +93,7 @@ async def async_get_config_entry_diagnostics( { "config": zha_data.yaml_config, "config_entry": config_entry.as_dict(), - "application_state": shallow_asdict(app.state), + "application_state": get_application_state_diagnostics(app), "energy_scan": { channel: 100 * energy / 255 for channel, energy in energy_scan.items() }, diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 35eb320893f..4d90942fb97 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -36,6 +36,7 @@ }), 'network_key': '**REDACTED**', 'nwk_addresses': dict({ + '11:22:33:44:55:66:77:88': 4660, }), 'nwk_manager_id': 0, 'nwk_update_id': 0, diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 0e78a9a1b5b..d32dd191527 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from zigpy.profiles import zha +from zigpy.types import EUI64, NWK from zigpy.zcl.clusters import security from homeassistant.components.zha.helpers import ( @@ -71,6 +72,10 @@ async def test_diagnostics_for_config_entry( gateway.application_controller.energy_scan.side_effect = None gateway.application_controller.energy_scan.return_value = scan + gateway.application_controller.state.network_info.nwk_addresses = { + EUI64.convert("11:22:33:44:55:66:77:88"): NWK(0x1234) + } + diagnostics_data = await get_diagnostics_for_config_entry( hass, hass_client, config_entry ) From b126f3fa6615daf6139153463c7e28d4e44099b7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:27:17 -0400 Subject: [PATCH 1023/1118] Bump ZHA to 0.0.68 (#150208) --- 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 9842fa7a0f3..5cad3c823b8 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.67"], + "requirements": ["zha==0.0.68"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 8e644cc2f41..51b4a5d34e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3200,7 +3200,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.67 +zha==0.0.68 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5aa75e48096..ac747cfdaa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2644,7 +2644,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.67 +zha==0.0.68 # homeassistant.components.zwave_js zwave-js-server-python==0.67.1 From eb6ae9d2d642495c3f18703ac42e0293c22b5ffa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:28:51 +0200 Subject: [PATCH 1024/1118] Bump actions/cache from 4.2.3 to 4.2.4 (#150253) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aca149bf020..12dbe60f146 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -255,7 +255,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv key: >- @@ -271,7 +271,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -301,7 +301,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -310,7 +310,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -341,7 +341,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -350,7 +350,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -381,7 +381,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -390,7 +390,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -497,7 +497,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: venv key: >- @@ -505,7 +505,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -593,7 +593,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -683,7 +683,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -726,7 +726,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -773,7 +773,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -825,7 +825,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -833,7 +833,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.2.4 with: path: .mypy_cache key: >- @@ -895,7 +895,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -956,7 +956,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1089,7 +1089,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1231,7 +1231,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true @@ -1390,7 +1390,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.3 + uses: actions/cache/restore@v4.2.4 with: path: venv fail-on-cache-miss: true From 712115cdb8d64321d8bd9d510b6e6f6240de466c Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 8 Aug 2025 19:33:16 +0200 Subject: [PATCH 1025/1118] Bump airOS to 0.2.6 improving device class matching more devices (#150134) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airos/conftest.py | 2 +- ..._ap-ptp.json => airos_loco5ac_ap-ptp.json} | 509 ++++++++++-------- .../airos/snapshots/test_diagnostics.ambr | 10 + 6 files changed, 292 insertions(+), 235 deletions(-) rename tests/components/airos/fixtures/{airos_ap-ptp.json => airos_loco5ac_ap-ptp.json} (80%) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 758902bbaa2..b9bd2db1ae4 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.4"] + "requirements": ["airos==0.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51b4a5d34e4..dd27ec0f8fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.4 +airos==0.2.6 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac747cfdaa2..1e42f602773 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.4 +airos==0.2.6 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/tests/components/airos/conftest.py b/tests/components/airos/conftest.py index b17908e801a..5443f79a976 100644 --- a/tests/components/airos/conftest.py +++ b/tests/components/airos/conftest.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture def ap_fixture(): """Load fixture data for AP mode.""" - json_data = load_json_object_fixture("airos_ap-ptp.json", DOMAIN) + json_data = load_json_object_fixture("airos_loco5ac_ap-ptp.json", DOMAIN) return AirOSData.from_dict(json_data) diff --git a/tests/components/airos/fixtures/airos_ap-ptp.json b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json similarity index 80% rename from tests/components/airos/fixtures/airos_ap-ptp.json rename to tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json index 06d13ba1101..a033a82411c 100644 --- a/tests/components/airos/fixtures/airos_ap-ptp.json +++ b/tests/components/airos/fixtures/airos_loco5ac_ap-ptp.json @@ -1,132 +1,194 @@ { "chain_names": [ - { "number": 1, "name": "Chain 0" }, - { "number": 2, "name": "Chain 1" } + { + "name": "Chain 0", + "number": 1 + }, + { + "name": "Chain 1", + "number": 2 + } ], - "host": { - "hostname": "NanoStation 5AC ap name", - "device_id": "03aa0d0b40fed0a47088293584ef5432", - "uptime": 264888, - "power_time": 268683, - "time": "2025-06-23 23:06:42", - "timestamp": 2668313184, - "fwversion": "v8.7.17", - "devmodel": "NanoStation 5AC loco", - "netrole": "bridge", - "loadavg": 0.412598, - "totalram": 63447040, - "freeram": 16564224, - "temperature": 0, - "cpuload": 10.10101, - "height": 3 - }, - "genuine": "/images/genuine.png", - "services": { - "dhcpc": false, - "dhcpd": false, - "dhcp6d_stateful": false, - "pppoe": false, - "airview": 2 + "derived": { + "access_point": true, + "mac": "01:23:45:67:89:AB", + "mac_interface": "br0", + "ptmp": false, + "ptp": true, + "station": false }, "firewall": { - "iptables": false, + "eb6tables": false, "ebtables": false, "ip6tables": false, - "eb6tables": false + "iptables": false }, + "genuine": "/images/genuine.png", + "gps": { + "fix": 0, + "lat": 52.379894, + "lon": 4.901608 + }, + "host": { + "cpuload": 10.10101, + "device_id": "03aa0d0b40fed0a47088293584ef5432", + "devmodel": "NanoStation 5AC loco", + "freeram": 16564224, + "fwversion": "v8.7.17", + "height": 3, + "hostname": "NanoStation 5AC ap name", + "loadavg": 0.412598, + "netrole": "bridge", + "power_time": 268683, + "temperature": 0, + "time": "2025-06-23 23:06:42", + "timestamp": 2668313184, + "totalram": 63447040, + "uptime": 264888 + }, + "interfaces": [ + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "eth0", + "mtu": 1500, + "status": { + "cable_len": 18, + "duplex": true, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": true, + "rx_bytes": 3984971949, + "rx_dropped": 0, + "rx_errors": 4, + "rx_packets": 73564835, + "snr": [30, 30, 30, 30], + "speed": 1000, + "tx_bytes": 209900085624, + "tx_dropped": 10, + "tx_errors": 0, + "tx_packets": 185866883 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "ath0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": null, + "ipaddr": "0.0.0.0", + "plugged": false, + "rx_bytes": 206938324766, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 149767200, + "snr": null, + "speed": 0, + "tx_bytes": 5265602738, + "tx_dropped": 2005, + "tx_errors": 0, + "tx_packets": 52980390 + } + }, + { + "enabled": true, + "hwaddr": "01:23:45:67:89:AB", + "ifname": "br0", + "mtu": 1500, + "status": { + "cable_len": null, + "duplex": false, + "ip6addr": [ + { + "addr": "fe80::eea:14ff:fea4:89cd", + "plen": 64 + } + ], + "ipaddr": "192.168.1.2", + "plugged": true, + "rx_bytes": 204802727, + "rx_dropped": 0, + "rx_errors": 0, + "rx_packets": 1791592, + "snr": null, + "speed": 0, + "tx_bytes": 236295176, + "tx_dropped": 0, + "tx_errors": 0, + "tx_packets": 298119 + } + } + ], + "ntpclient": {}, "portfw": false, + "provmode": {}, + "services": { + "airview": 2, + "dhcp6d_stateful": false, + "dhcpc": false, + "dhcpd": false, + "pppoe": false + }, + "unms": { + "status": 0, + "timestamp": null + }, "wireless": { - "essid": "DemoSSID", - "mode": "ap-ptp", - "ieeemode": "11ACVHT80", - "band": 2, - "compat_11n": 0, - "hide_essid": 0, - "apmac": "01:23:45:67:89:AB", "antenna_gain": 13, - "frequency": 5500, - "center1_freq": 5530, - "dfs": 1, - "distance": 0, - "security": "WPA2", - "noisef": -89, - "txpower": -3, + "apmac": "01:23:45:67:89:AB", "aprepeater": false, - "rstatus": 5, - "chanbw": 80, - "rx_chainmask": 3, - "tx_chainmask": 3, - "nol_state": 0, - "nol_timeout": 0, + "band": 2, "cac_state": 0, "cac_timeout": 0, - "rx_idx": 8, - "rx_nss": 2, - "tx_idx": 9, - "tx_nss": 2, - "throughput": { "tx": 222, "rx": 9907 }, - "service": { "time": 267181, "link": 266003 }, + "center1_freq": 5530, + "chanbw": 80, + "compat_11n": 0, + "count": 1, + "dfs": 1, + "distance": 0, + "essid": "DemoSSID", + "frequency": 5500, + "hide_essid": 0, + "ieeemode": "11ACVHT80", + "mode": "ap-ptp", + "noisef": -89, + "nol_state": 0, + "nol_timeout": 0, "polling": { + "atpc_status": 2, "cb_capacity": 593970, "dl_capacity": 647400, - "ul_capacity": 540540, - "use": 48, - "tx_use": 6, - "rx_use": 42, - "atpc_status": 2, + "ff_cap_rep": false, "fixed_frame": false, + "flex_mode": null, "gps_sync": false, - "ff_cap_rep": false + "rx_use": 42, + "tx_use": 6, + "ul_capacity": 540540, + "use": 48 + }, + "rstatus": 5, + "rx_chainmask": 3, + "rx_idx": 8, + "rx_nss": 2, + "security": "WPA2", + "service": { + "link": 266003, + "time": 267181 }, - "count": 1, "sta": [ { - "mac": "01:23:45:67:89:AB", - "lastip": "192.168.1.2", - "signal": -59, - "rssi": 37, - "noisefloor": -89, - "chainrssi": [35, 32, 0], - "tx_idx": 9, - "rx_idx": 8, - "tx_nss": 2, - "rx_nss": 2, - "tx_latency": 0, - "distance": 1, - "tx_packets": 0, - "tx_lretries": 0, - "tx_sretries": 0, - "uptime": 170281, - "dl_signal_expect": -80, - "ul_signal_expect": -55, - "cb_capacity_expect": 416000, - "dl_capacity_expect": 208000, - "ul_capacity_expect": 624000, - "dl_rate_expect": 3, - "ul_rate_expect": 8, - "dl_linkscore": 100, - "ul_linkscore": 86, - "dl_avg_linkscore": 100, - "ul_avg_linkscore": 88, - "tx_ratedata": [175, 4, 47, 200, 673, 158, 163, 138, 68895, 19577430], - "stats": { - "rx_bytes": 206938324814, - "rx_packets": 149767200, - "rx_pps": 846, - "tx_bytes": 5265602739, - "tx_packets": 52980390, - "tx_pps": 0 - }, "airmax": { "actual_priority": 0, - "beam": 0, - "desired_priority": 0, - "cb_capacity": 593970, - "dl_capacity": 647400, - "ul_capacity": 540540, "atpc_status": 2, + "beam": 0, + "cb_capacity": 593970, + "desired_priority": 0, + "dl_capacity": 647400, "rx": { - "usage": 42, "cinr": 31, "evm": [ [ @@ -141,10 +203,10 @@ 34, 33, 34, 34, 34, 34, 34, 35, 35, 35, 34, 35, 33, 34, 34, 34, 34, 35, 35, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 35, 35 ] - ] + ], + "usage": 42 }, "tx": { - "usage": 6, "cinr": 31, "evm": [ [ @@ -159,142 +221,127 @@ 38, 37, 37, 37, 38, 37, 38, 37, 37, 37, 37, 37, 36, 37, 37, 37, 37, 37, 37, 38, 37, 37, 38, 37, 36, 37, 37, 37, 37, 37, 37, 37 ] - ] - } + ], + "usage": 6 + }, + "ul_capacity": 540540 }, + "airos_connected": true, + "cb_capacity_expect": 416000, + "chainrssi": [35, 32, 0], + "distance": 1, + "dl_avg_linkscore": 100, + "dl_capacity_expect": 208000, + "dl_linkscore": 100, + "dl_rate_expect": 3, + "dl_signal_expect": -80, "last_disc": 1, + "lastip": "192.168.1.2", + "mac": "01:23:45:67:89:AB", + "noisefloor": -89, "remote": { "age": 1, - "device_id": "d4f4cdf82961e619328a8f72f8d7653b", - "hostname": "NanoStation 5AC sta name", - "platform": "NanoStation 5AC loco", - "version": "WA.ar934x.v8.7.17.48152.250620.2132", - "time": "2025-06-23 23:13:54", - "cpuload": 43.564301, - "temperature": 0, - "totalram": 63447040, - "freeram": 14290944, - "netrole": "bridge", - "mode": "sta-ptp", - "sys_id": "0xe7fa", - "tx_throughput": 16023, - "rx_throughput": 251, - "uptime": 265320, - "power_time": 268512, - "compat_11n": 0, - "signal": -58, - "rssi": 38, - "noisefloor": -90, - "tx_power": -4, - "distance": 1, - "rx_chainmask": 3, + "airview": 2, + "antenna_gain": 13, + "cable_loss": 0, "chainrssi": [33, 37, 0], + "compat_11n": 0, + "cpuload": 43.564301, + "device_id": "d4f4cdf82961e619328a8f72f8d7653b", + "distance": 1, + "ethlist": [ + { + "cable_len": 14, + "duplex": true, + "enabled": true, + "ifname": "eth0", + "plugged": true, + "snr": [30, 30, 29, 30], + "speed": 1000 + } + ], + "freeram": 14290944, + "gps": { + "alt": null, + "dim": null, + "dop": null, + "fix": 0, + "lat": 52.379894, + "lon": 4.901608, + "sats": null, + "time_synced": null + }, + "height": 2, + "hostname": "NanoStation 5AC sta name", + "ip6addr": ["fe80::eea:14ff:fea4:89ab"], + "ipaddr": ["192.168.1.2"], + "mode": "sta-ptp", + "netrole": "bridge", + "noisefloor": -90, + "oob": false, + "platform": "NanoStation 5AC loco", + "power_time": 268512, + "rssi": 38, + "rx_bytes": 3624206478, + "rx_chainmask": 3, + "rx_throughput": 251, + "service": { + "link": 265996, + "time": 267195 + }, + "signal": -58, + "sys_id": "0xe7fa", + "temperature": 0, + "time": "2025-06-23 23:13:54", + "totalram": 63447040, + "tx_bytes": 212308148210, + "tx_power": -4, "tx_ratedata": [ 14, 4, 372, 2223, 4708, 4037, 8142, 485763, 29420892, 24748154 ], - "tx_bytes": 212308148210, - "rx_bytes": 3624206478, - "antenna_gain": 13, - "cable_loss": 0, - "height": 2, - "ethlist": [ - { - "ifname": "eth0", - "enabled": true, - "plugged": true, - "duplex": true, - "speed": 1000, - "snr": [30, 30, 29, 30], - "cable_len": 14 - } - ], - "ipaddr": ["192.168.1.2"], - "ip6addr": ["fe80::eea:14ff:fea4:89ab"], - "gps": { "lat": "52.379894", "lon": "4.901608", "fix": 0 }, - "oob": false, - "unms": { "status": 0, "timestamp": null }, - "airview": 2, - "service": { "time": 267195, "link": 265996 } + "tx_throughput": 16023, + "unms": { + "status": 0, + "timestamp": null + }, + "uptime": 265320, + "version": "WA.ar934x.v8.7.17.48152.250620.2132" }, - "airos_connected": true + "rssi": 37, + "rx_idx": 8, + "rx_nss": 2, + "signal": -59, + "stats": { + "rx_bytes": 206938324814, + "rx_packets": 149767200, + "rx_pps": 846, + "tx_bytes": 5265602739, + "tx_packets": 52980390, + "tx_pps": 0 + }, + "tx_idx": 9, + "tx_latency": 0, + "tx_lretries": 0, + "tx_nss": 2, + "tx_packets": 0, + "tx_ratedata": [175, 4, 47, 200, 673, 158, 163, 138, 68895, 19577430], + "tx_sretries": 0, + "ul_avg_linkscore": 88, + "ul_capacity_expect": 624000, + "ul_linkscore": 86, + "ul_rate_expect": 8, + "ul_signal_expect": -55, + "uptime": 170281 } ], - "sta_disconnected": [] - }, - "interfaces": [ - { - "ifname": "eth0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": true, - "tx_bytes": 209900085624, - "rx_bytes": 3984971949, - "tx_packets": 185866883, - "rx_packets": 73564835, - "tx_errors": 0, - "rx_errors": 4, - "tx_dropped": 10, - "rx_dropped": 0, - "ipaddr": "0.0.0.0", - "speed": 1000, - "duplex": true, - "snr": [30, 30, 30, 30], - "cable_len": 18, - "ip6addr": null - } + "sta_disconnected": [], + "throughput": { + "rx": 9907, + "tx": 222 }, - { - "ifname": "ath0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": false, - "tx_bytes": 5265602738, - "rx_bytes": 206938324766, - "tx_packets": 52980390, - "rx_packets": 149767200, - "tx_errors": 0, - "rx_errors": 0, - "tx_dropped": 2005, - "rx_dropped": 0, - "ipaddr": "0.0.0.0", - "speed": 0, - "duplex": false, - "snr": null, - "cable_len": null, - "ip6addr": null - } - }, - { - "ifname": "br0", - "hwaddr": "01:23:45:67:89:AB", - "enabled": true, - "mtu": 1500, - "status": { - "plugged": true, - "tx_bytes": 236295176, - "rx_bytes": 204802727, - "tx_packets": 298119, - "rx_packets": 1791592, - "tx_errors": 0, - "rx_errors": 0, - "tx_dropped": 0, - "rx_dropped": 0, - "ipaddr": "192.168.1.2", - "speed": 0, - "duplex": false, - "snr": null, - "cable_len": null, - "ip6addr": [{ "addr": "fe80::eea:14ff:fea4:89cd", "plen": 64 }] - } - } - ], - "provmode": {}, - "ntpclient": {}, - "unms": { "status": 0, "timestamp": null }, - "gps": { "lat": 52.379894, "lon": 4.901608, "fix": 0 }, - "derived": { "mac": "01:23:45:67:89:AB", "mac_interface": "br0" } + "tx_chainmask": 3, + "tx_idx": 9, + "tx_nss": 2, + "txpower": -3 + } } diff --git a/tests/components/airos/snapshots/test_diagnostics.ambr b/tests/components/airos/snapshots/test_diagnostics.ambr index bc2dedc905a..574dbf68949 100644 --- a/tests/components/airos/snapshots/test_diagnostics.ambr +++ b/tests/components/airos/snapshots/test_diagnostics.ambr @@ -13,8 +13,12 @@ }), ]), 'derived': dict({ + 'access_point': True, 'mac': '**REDACTED**', 'mac_interface': 'br0', + 'ptmp': False, + 'ptp': True, + 'station': False, }), 'firewall': dict({ 'eb6tables': False, @@ -164,6 +168,7 @@ 'dl_capacity': 647400, 'ff_cap_rep': False, 'fixed_frame': False, + 'flex_mode': None, 'gps_sync': False, 'rx_use': 42, 'tx_use': 6, @@ -515,9 +520,14 @@ ]), 'freeram': 14290944, 'gps': dict({ + 'alt': None, + 'dim': None, + 'dop': None, 'fix': 0, 'lat': '**REDACTED**', 'lon': '**REDACTED**', + 'sats': None, + 'time_synced': None, }), 'height': 2, 'hostname': '**REDACTED**', From f2c9cdb09e30a36bcd7d54886c23c5bb331d0b9e Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Fri, 8 Aug 2025 08:31:34 -1000 Subject: [PATCH 1026/1118] Add quality scale for APCUPSD integration (#146999) --- .../components/apcupsd/manifest.json | 1 + .../components/apcupsd/quality_scale.yaml | 99 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/apcupsd/quality_scale.yaml diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 3713b74fff7..5e5a81c358a 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/apcupsd", "iot_class": "local_polling", "loggers": ["apcaccess"], + "quality_scale": "bronze", "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml new file mode 100644 index 00000000000..18fc15cd614 --- /dev/null +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -0,0 +1,99 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + Consider deriving a base entity. + config-flow-test-coverage: + status: done + comment: | + Consider looking into making a `mock_setup_entry` fixture that just automatically do this. + `test_config_flow_cannot_connect`: Needs to end in CREATE_ENTRY to test that its able to recover. + `test_config_flow_duplicate`: this test should be split in 2, one for testing duplicate host/port and one for duplicate serial number. + `test_flow_works`: Should also test unique id. + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + The integration does not require authentication. + test-coverage: + status: todo + comment: | + Patch `aioapcaccess.request_status` where we use it. + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered. + discovery: + status: exempt + comment: | + This integration cannot be discovered. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + The integration connects to a single service per configuration entry. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connect to a single service per configuration entry. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + The integration does not connect via HTTP. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1d6db8e1f7a..0050cddc708 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -160,7 +160,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "anthropic", "aosmith", "apache_kafka", - "apcupsd", "apple_tv", "apprise", "aprilaire", @@ -1191,7 +1190,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "anthropic", "aosmith", "apache_kafka", - "apcupsd", "apple_tv", "apprise", "aprilaire", From 9f1fe8a06764eedc8d5228c6ad0576dd296977f6 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 8 Aug 2025 20:34:40 +0200 Subject: [PATCH 1027/1118] Add binary_sensor to UISP airOS (#149803) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/airos/__init__.py | 5 +- .../components/airos/binary_sensor.py | 106 ++++++++ .../components/airos/quality_scale.yaml | 4 +- homeassistant/components/airos/strings.json | 17 ++ tests/components/airos/__init__.py | 16 +- .../airos/snapshots/test_binary_sensor.ambr | 245 ++++++++++++++++++ tests/components/airos/test_binary_sensor.py | 28 ++ tests/components/airos/test_sensor.py | 8 +- 8 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/airos/binary_sensor.py create mode 100644 tests/components/airos/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/airos/test_binary_sensor.py diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index 54f0db205a9..ea184e5613d 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -10,7 +10,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py new file mode 100644 index 00000000000..e743cda4c63 --- /dev/null +++ b/homeassistant/components/airos/binary_sensor.py @@ -0,0 +1,106 @@ +"""AirOS Binary Sensor component for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator +from .entity import AirOSEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe an AirOS binary sensor.""" + + value_fn: Callable[[AirOSData], bool] + + +BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( + AirOSBinarySensorEntityDescription( + key="portfw", + translation_key="port_forwarding", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.portfw, + ), + AirOSBinarySensorEntityDescription( + key="dhcp_client", + translation_key="dhcp_client", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcpc, + ), + AirOSBinarySensorEntityDescription( + key="dhcp_server", + translation_key="dhcp_server", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcpd, + entity_registry_enabled_default=False, + ), + AirOSBinarySensorEntityDescription( + key="dhcp6_server", + translation_key="dhcp6_server", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.dhcp6d_stateful, + entity_registry_enabled_default=False, + ), + AirOSBinarySensorEntityDescription( + key="pppoe", + translation_key="pppoe", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.services.pppoe, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS binary sensors from a config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities( + AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class AirOSBinarySensor(AirOSEntity, BinarySensorEntity): + """Representation of a binary sensor.""" + + entity_description: AirOSBinarySensorEntityDescription + + def __init__( + self, + coordinator: AirOSDataUpdateCoordinator, + description: AirOSBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/airos/quality_scale.yaml b/homeassistant/components/airos/quality_scale.yaml index c8c5d209af5..e8a5ce8ed89 100644 --- a/homeassistant/components/airos/quality_scale.yaml +++ b/homeassistant/components/airos/quality_scale.yaml @@ -54,9 +54,7 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: - status: todo - comment: prepared binary_sensors will provide this + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index ff013862ee5..b11a87d7f75 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -26,6 +26,23 @@ } }, "entity": { + "binary_sensor": { + "port_forwarding": { + "name": "Port forwarding" + }, + "dhcp_client": { + "name": "DHCP client" + }, + "dhcp_server": { + "name": "DHCP server" + }, + "dhcp6_server": { + "name": "DHCPv6 server" + }, + "pppoe": { + "name": "PPPoE link" + } + }, "sensor": { "host_cpuload": { "name": "CPU load" diff --git a/tests/components/airos/__init__.py b/tests/components/airos/__init__.py index 8c6182a8650..f663644a8a4 100644 --- a/tests/components/airos/__init__.py +++ b/tests/components/airos/__init__.py @@ -1,13 +1,19 @@ """Tests for the Ubiquity airOS integration.""" +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform] | None = None, +) -> None: """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.airos._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airos/snapshots/test_binary_sensor.ambr b/tests/components/airos/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..d9815e0c62b --- /dev/null +++ b/tests/components/airos/snapshots/test_binary_sensor.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-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.nanostation_5ac_ap_name_dhcp_client', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP client', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_client', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_client', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP client', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-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.nanostation_5ac_ap_name_dhcp_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCP server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcp_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCP server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcp_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-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.nanostation_5ac_ap_name_dhcpv6_server', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHCPv6 server', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhcp6_server', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_dhcp6_server', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_dhcpv6_server-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'NanoStation 5AC ap name DHCPv6 server', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_dhcpv6_server', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-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.nanostation_5ac_ap_name_port_forwarding', + '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': 'Port forwarding', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'port_forwarding', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_portfw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_port_forwarding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NanoStation 5AC ap name Port forwarding', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_port_forwarding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-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.nanostation_5ac_ap_name_pppoe_link', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PPPoE link', + 'platform': 'airos', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pppoe', + 'unique_id': '03aa0d0b40fed0a47088293584ef5432_pppoe', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.nanostation_5ac_ap_name_pppoe_link-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'NanoStation 5AC ap name PPPoE link', + }), + 'context': , + 'entity_id': 'binary_sensor.nanostation_5ac_ap_name_pppoe_link', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/airos/test_binary_sensor.py b/tests/components/airos/test_binary_sensor.py new file mode 100644 index 00000000000..40c3d631cd3 --- /dev/null +++ b/tests/components/airos/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Test the Ubiquiti airOS binary sensors.""" + +from unittest.mock import AsyncMock + +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 . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airos_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/airos/test_sensor.py b/tests/components/airos/test_sensor.py index c9e675e7987..7f39f504753 100644 --- a/tests/components/airos/test_sensor.py +++ b/tests/components/airos/test_sensor.py @@ -13,7 +13,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.airos.const 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 @@ -31,7 +31,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -53,7 +53,7 @@ async def test_sensor_update_exception_handling( freezer: FrozenDateTimeFactory, ) -> None: """Test entity update data handles exceptions.""" - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) expected_entity_id = "sensor.nanostation_5ac_ap_name_antenna_gain" signal_state = hass.states.get(expected_entity_id) @@ -65,7 +65,7 @@ async def test_sensor_update_exception_handling( mock_airos_client.login.side_effect = exception - freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds() + 1)) + freezer.tick(timedelta(seconds=SCAN_INTERVAL.total_seconds())) async_fire_time_changed(hass) await hass.async_block_till_done() From 91a1ca09f7d271e398ebb3ae25071bacda65c71e Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 8 Aug 2025 21:49:09 +0300 Subject: [PATCH 1028/1118] Add GPT-5 support (#150281) --- .../openai_conversation/config_flow.py | 26 ++++++++++- .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 12 +++-- .../openai_conversation/strings.json | 8 ++++ .../openai_conversation/conftest.py | 2 +- .../openai_conversation/test_config_flow.py | 46 +++++++++++++------ 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c45c2b997b3..0b2fa75b5c0 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -49,6 +49,7 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_VERBOSITY, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -67,6 +68,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_VERBOSITY, RECOMMENDED_WEB_SEARCH, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_USER_LOCATION, @@ -323,7 +325,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): model = options[CONF_CHAT_MODEL] - if model.startswith("o"): + if model.startswith(("o", "gpt-5")): step_schema.update( { vol.Optional( @@ -331,7 +333,9 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): default=RECOMMENDED_REASONING_EFFORT, ): SelectSelector( SelectSelectorConfig( - options=["low", "medium", "high"], + options=["low", "medium", "high"] + if model.startswith("o") + else ["minimal", "low", "medium", "high"], translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) @@ -341,6 +345,24 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) + if model.startswith("gpt-5"): + step_schema.update( + { + vol.Optional( + CONF_VERBOSITY, + default=RECOMMENDED_VERBOSITY, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_VERBOSITY, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + elif CONF_VERBOSITY in options: + options.pop(CONF_VERBOSITY) + if self._subentry_type == "conversation" and not model.startswith( tuple(UNSUPPORTED_WEB_SEARCH_MODELS) ): diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index cacef6fcff9..2fd18913207 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -21,6 +21,7 @@ CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" +CONF_VERBOSITY = "verbosity" CONF_WEB_SEARCH = "web_search" CONF_WEB_SEARCH_USER_LOCATION = "user_location" CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size" @@ -34,6 +35,7 @@ RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 +RECOMMENDED_VERBOSITY = "medium" RECOMMENDED_WEB_SEARCH = False RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" RECOMMENDED_WEB_SEARCH_USER_LOCATION = False diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index c1b2f970f07..748c0c8f874 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -61,6 +61,7 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + CONF_VERBOSITY, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -75,6 +76,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_VERBOSITY, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, ) @@ -346,14 +348,18 @@ class OpenAIBaseLLMEntity(Entity): if tools: model_args["tools"] = tools - if model_args["model"].startswith("o"): + if model_args["model"].startswith(("o", "gpt-5")): model_args["reasoning"] = { "effort": options.get( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) } - else: - model_args["store"] = False + model_args["include"] = ["reasoning.encrypted_content"] + + if model_args["model"].startswith("gpt-5"): + model_args["text"] = { + "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) + } messages = [ m diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index a1bf236f19b..304ef8b6bdc 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -121,6 +121,7 @@ "selector": { "reasoning_effort": { "options": { + "minimal": "Minimal", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" @@ -132,6 +133,13 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "verbosity": { + "options": { + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } } }, "services": { diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index b58e6c31f38..38d8967e6c5 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -94,7 +94,7 @@ def mock_config_entry_with_reasoning_model( hass.config_entries.async_update_subentry( mock_config_entry, next(iter(mock_config_entry.subentries.values())), - data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "o4-mini"}, + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) return mock_config_entry diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 6d8fb143f88..3f3b7801c8f 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -20,6 +20,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_VERBOSITY, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -302,7 +303,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT: "Speak like a pro", }, { CONF_TEMPERATURE: 1.0, @@ -317,7 +318,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ), { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", + CONF_PROMPT: "Speak like a pro", CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: "o1-pro", CONF_TOP_P: RECOMMENDED_TOP_P, @@ -414,35 +415,51 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( # Case 2: reasoning model { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, - CONF_REASONING_EFFORT: "high", + CONF_REASONING_EFFORT: "low", + CONF_VERBOSITY: "high", + CONF_CODE_INTERPRETER: False, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ( { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, - {CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: False}, + { + CONF_REASONING_EFFORT: "minimal", + CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, ), { CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pro", + CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o1-pro", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, - CONF_REASONING_EFFORT: "high", + CONF_REASONING_EFFORT: "minimal", CONF_CODE_INTERPRETER: False, + CONF_VERBOSITY: "high", + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), # Test that old options are removed after reconfiguration @@ -482,11 +499,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-4o", + CONF_CHAT_MODEL: "gpt-5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "low", + CONF_WEB_SEARCH: False, }, ( { @@ -550,11 +569,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "o3-mini", + CONF_CHAT_MODEL: "o5", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", CONF_CODE_INTERPRETER: True, + CONF_VERBOSITY: "medium", }, ( { From 94191239c61e8796cba163095a600efc3b0afb76 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 8 Aug 2025 20:50:14 +0200 Subject: [PATCH 1029/1118] Remove misleading "the" from Launch Library configuration (#150288) --- homeassistant/components/launch_library/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/launch_library/strings.json b/homeassistant/components/launch_library/strings.json index a587544f836..219d71600bc 100644 --- a/homeassistant/components/launch_library/strings.json +++ b/homeassistant/components/launch_library/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Do you want to configure the Launch Library?" + "description": "Do you want to configure Launch Library?" } } }, From 13e592edaf55fac6281e334933b6e68dbaa226d9 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Fri, 8 Aug 2025 21:51:49 +0300 Subject: [PATCH 1030/1118] Bump anthropic to 0.62.0 (#150284) --- homeassistant/components/anthropic/const.py | 8 +++----- homeassistant/components/anthropic/entity.py | 5 ++++- homeassistant/components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index a1637a8cef6..356140ff66e 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -20,10 +20,8 @@ RECOMMENDED_THINKING_BUDGET = 0 MIN_THINKING_BUDGET = 1024 THINKING_MODELS = [ - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-latest", - "claude-opus-4-20250514", - "claude-opus-4-0", - "claude-sonnet-4-20250514", + "claude-3-7-sonnet", "claude-sonnet-4-0", + "claude-opus-4-0", + "claude-opus-4-1", ] diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 636417dd43b..a58130ccd92 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -361,7 +361,10 @@ class AnthropicBaseLLMEntity(Entity): "system": system.content, "stream": True, } - if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + if ( + model.startswith(tuple(THINKING_MODELS)) + and thinking_budget >= MIN_THINKING_BUDGET + ): model_args["thinking"] = ThinkingConfigEnabledParam( type="enabled", budget_tokens=thinking_budget ) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 6a8f1e5e54c..6fed0282a00 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.52.0"] + "requirements": ["anthropic==0.62.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd27ec0f8fa..2cdb87b5ad2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -495,7 +495,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.52.0 +anthropic==0.62.0 # homeassistant.components.mcp_server anyio==4.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e42f602773..9dfd138977c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -468,7 +468,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.52.0 +anthropic==0.62.0 # homeassistant.components.mcp_server anyio==4.9.0 From 1a654cd35d17d27bdb394a051adb26c9fcdb0a07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 8 Aug 2025 20:52:03 +0200 Subject: [PATCH 1031/1118] Use common strings "Low"/"High" for more states in `tuya` (#150283) --- homeassistant/components/tuya/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index d660c9c910d..16e7e555485 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -472,7 +472,7 @@ }, "blanket_level": { "state": { - "level_1": "Low", + "level_1": "[%key:common::state::low%]", "level_2": "Level 2", "level_3": "Level 3", "level_4": "Level 4", @@ -481,7 +481,7 @@ "level_7": "Level 7", "level_8": "Level 8", "level_9": "Level 9", - "level_10": "High" + "level_10": "[%key:common::state::high%]" } }, "odor_elimination_mode": { From 823d20c67f59004cc6d6c0c9f0ee340f860b698d Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 8 Aug 2025 21:28:29 +0200 Subject: [PATCH 1032/1118] Volvo: fix distance to empty battery (#150278) --- homeassistant/components/volvo/sensor.py | 20 +++++++++---------- .../xc40_electric_2024/energy_state.json | 4 ++-- .../volvo/snapshots/test_sensor.ambr | 4 ++-- tests/components/volvo/test_sensor.py | 16 +++++++++++++++ 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index 647c7b578e8..caadebb6e2a 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import dataclass import logging from typing import Any, cast @@ -47,7 +47,6 @@ _LOGGER = logging.getLogger(__name__) class VolvoSensorDescription(VolvoEntityDescription, SensorEntityDescription): """Describes a Volvo sensor entity.""" - source_fields: list[str] | None = None value_fn: Callable[[VolvoCarsValue], Any] | None = None @@ -240,11 +239,15 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( "none", ], ), - # statistics & energy state endpoint + # statistics endpoint + # We're not using `electricRange` from the energy state endpoint because + # the official app seems to use `distanceToEmptyBattery`. + # In issue #150213, a user described to behavior as follows: + # - For a `distanceToEmptyBattery` of 250km, the `electricRange` was 150mi + # - For a `distanceToEmptyBattery` of 260km, the `electricRange` was 160mi VolvoSensorDescription( key="distance_to_empty_battery", - api_field="", - source_fields=["distanceToEmptyBattery", "electricRange"], + api_field="distanceToEmptyBattery", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -362,12 +365,7 @@ async def async_setup_entry( if description.key in added_keys: continue - if description.source_fields: - for field in description.source_fields: - if field in coordinator.data: - description = replace(description, api_field=field) - _add_entity(coordinator, description) - elif description.api_field in coordinator.data: + if description.api_field in coordinator.data: _add_entity(coordinator, description) async_add_entities(entities) diff --git a/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json index 16208571c47..bac596857b0 100644 --- a/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json +++ b/tests/components/volvo/fixtures/xc40_electric_2024/energy_state.json @@ -7,8 +7,8 @@ }, "electricRange": { "status": "OK", - "value": 220, - "unit": "km", + "value": 150, + "unit": "mi", "updatedAt": "2025-07-02T08:51:23Z" }, "chargerConnectionStatus": { diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index b651bbd526f..53e05c49c97 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -585,7 +585,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '327', + 'state': '250', }) # --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_distance_to_service-entry] @@ -2514,7 +2514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '220', + 'state': '250', }) # --- # name: test_sensor[xc40_electric_2024][sensor.volvo_xc40_distance_to_service-entry] diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index f610ee2ed57..e4cc69470ae 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -30,3 +30,19 @@ async def test_sensor( assert await setup_integration() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "full_model", + ["xc40_electric_2024"], +) +async def test_distance_to_empty_battery( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], +) -> None: + """Test using `distanceToEmptyBattery` instead of `electricRange`.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250" From bf64e1196027f9cb6b4bb4ae9e8e43604751e8fe Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 8 Aug 2025 21:38:27 +0200 Subject: [PATCH 1033/1118] Migrate unique_id only if monitor_id is present in Uptime Kuma (#150197) --- homeassistant/components/uptime_kuma/coordinator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 58eed420fd8..df64b12f8e9 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -104,7 +104,12 @@ def async_migrate_entities_unique_ids( f"{registry_entry.config_entry_id}_" ).removesuffix(f"_{registry_entry.translation_key}") if monitor := next( - (m for m in metrics.values() if m.monitor_name == name), None + ( + m + for m in metrics.values() + if m.monitor_name == name and m.monitor_id is not None + ), + None, ): entity_registry.async_update_entity( registry_entry.entity_id, From dff4f7992565f58ad74476a61a8d0fa6e27987be Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Fri, 8 Aug 2025 22:00:48 +0200 Subject: [PATCH 1034/1118] Remove useless strings from emoncms (#150182) --- homeassistant/components/emoncms/strings.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index f68ea92d26c..e41a7e8bd03 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -111,14 +111,6 @@ } }, "issues": { - "remove_value_template": { - "title": "The {domain} integration cannot start", - "description": "Configuring {domain} using YAML is being removed and the `{parameter}` parameter cannot be imported.\n\nPlease remove `{parameter}` from your `{domain}` yaml configuration and restart Home Assistant\n\nAlternatively, you may entirely remove the `{domain}` configuration from your configuration.yaml, restart Home Assistant, and add the {domain} integration manually." - }, - "missing_include_only_feed_id": { - "title": "No feed synchronized with the {domain} sensor", - "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." - }, "migrate_database": { "title": "Upgrade your emoncms version", "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\nPlease upgrade to at least version 11.5.7 and migrate your emoncms database.\n\nMore info in the [emoncms documentation]({doc_url})" From 981ae391829b1771025e8582163a741f693e22dd Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:11:32 -0400 Subject: [PATCH 1035/1118] Fix dialog enhancement switch for Sonos Arc Ultra (#150116) --- homeassistant/components/sonos/const.py | 3 ++ homeassistant/components/sonos/speaker.py | 8 ++++ homeassistant/components/sonos/switch.py | 51 ++++++++++++++++++---- tests/components/sonos/conftest.py | 20 +++++++++ tests/components/sonos/test_switch.py | 52 ++++++++++++++++++++++- 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 76e0a915060..440d9a3aea7 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -186,6 +186,9 @@ MODELS_TV_ONLY = ( "ULTRA", ) MODELS_LINEIN_AND_TV = ("AMP",) +MODEL_SONOS_ARC_ULTRA = "SONOS ARC ULTRA" + +ATTR_SPEECH_ENHANCEMENT_ENABLED = "speech_enhance_enabled" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f5cfb84ec36..894d32fcb97 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -35,6 +35,7 @@ from homeassistant.util import dt as dt_util from .alarms import SonosAlarms from .const import ( + ATTR_SPEECH_ENHANCEMENT_ENABLED, AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, DOMAIN, @@ -157,6 +158,7 @@ class SonosSpeaker: # Home theater self.audio_delay: int | None = None self.dialog_level: bool | None = None + self.speech_enhance_enabled: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None self.sub_crossover: int | None = None @@ -548,6 +550,11 @@ class SonosSpeaker: @callback def async_update_volume(self, event: SonosEvent) -> None: """Update information about currently volume settings.""" + _LOGGER.debug( + "Updating volume for %s with event variables: %s", + self.zone_name, + event.variables, + ) self.event_stats.process(event) variables = event.variables @@ -565,6 +572,7 @@ class SonosSpeaker: for bool_var in ( "dialog_level", + ATTR_SPEECH_ENHANCEMENT_ENABLED, "night_mode", "sub_enabled", "surround_enabled", diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 582845d10a2..653be229b22 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -19,7 +19,9 @@ from homeassistant.helpers.event import async_track_time_change from .alarms import SonosAlarms from .const import ( + ATTR_SPEECH_ENHANCEMENT_ENABLED, DOMAIN, + MODEL_SONOS_ARC_ULTRA, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, @@ -59,6 +61,7 @@ ALL_FEATURES = ( ATTR_SURROUND_ENABLED, ATTR_STATUS_LIGHT, ) +ALL_SUBST_FEATURES = (ATTR_SPEECH_ENHANCEMENT_ENABLED,) COORDINATOR_FEATURES = ATTR_CROSSFADE @@ -69,6 +72,14 @@ POLL_REQUIRED = ( WEEKEND_DAYS = (0, 6) +# Mapping of model names to feature attributes that need to be substituted. +# This is used to handle differences in attributes across Sonos models. +MODEL_FEATURE_SUBSTITUTIONS: dict[str, dict[str, str]] = { + MODEL_SONOS_ARC_ULTRA: { + ATTR_SPEECH_ENHANCEMENT: ATTR_SPEECH_ENHANCEMENT_ENABLED, + }, +} + async def async_setup_entry( hass: HomeAssistant, @@ -92,6 +103,13 @@ async def async_setup_entry( def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: features = [] + for feature_type in ALL_SUBST_FEATURES: + try: + if (state := getattr(speaker.soco, feature_type, None)) is not None: + setattr(speaker, feature_type, state) + except SoCoSlaveException: + pass + for feature_type in ALL_FEATURES: try: if (state := getattr(speaker.soco, feature_type, None)) is not None: @@ -107,12 +125,23 @@ async def async_setup_entry( available_soco_attributes, speaker ) for feature_type in available_features: + attribute_key = MODEL_FEATURE_SUBSTITUTIONS.get( + speaker.model_name.upper(), {} + ).get(feature_type, feature_type) _LOGGER.debug( - "Creating %s switch on %s", + "Creating %s switch on %s attribute %s", feature_type, speaker.zone_name, + attribute_key, + ) + entities.append( + SonosSwitchEntity( + feature_type=feature_type, + attribute_key=attribute_key, + speaker=speaker, + config_entry=config_entry, + ) ) - entities.append(SonosSwitchEntity(feature_type, speaker, config_entry)) async_add_entities(entities) config_entry.async_on_unload( @@ -127,11 +156,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): """Representation of a Sonos feature switch.""" def __init__( - self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + self, + feature_type: str, + attribute_key: str, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, ) -> None: """Initialize the switch.""" super().__init__(speaker, config_entry) - self.feature_type = feature_type + self.attribute_key = attribute_key self.needs_coordinator = feature_type in COORDINATOR_FEATURES self._attr_entity_category = EntityCategory.CONFIG self._attr_translation_key = feature_type @@ -149,15 +182,15 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): @soco_error() def poll_state(self) -> None: """Poll the current state of the switch.""" - state = getattr(self.soco, self.feature_type) - setattr(self.speaker, self.feature_type, state) + state = getattr(self.soco, self.attribute_key) + setattr(self.speaker, self.attribute_key, state) @property def is_on(self) -> bool: """Return True if entity is on.""" if self.needs_coordinator and not self.speaker.is_coordinator: - return cast(bool, getattr(self.speaker.coordinator, self.feature_type)) - return cast(bool, getattr(self.speaker, self.feature_type)) + return cast(bool, getattr(self.speaker.coordinator, self.attribute_key)) + return cast(bool, getattr(self.speaker, self.attribute_key)) def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -175,7 +208,7 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): else: soco = self.soco try: - setattr(soco, self.feature_type, enable) + setattr(soco, self.attribute_key, enable) except SoCoUPnPException as exc: _LOGGER.warning("Could not toggle %s: %s", self.entity_id, exc) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d3de2a889d5..0cdc17c55a6 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -882,3 +882,23 @@ def ungroup_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None: ) coordinator.zoneGroupTopology.subscribe.return_value._callback(event) group_member.zoneGroupTopology.subscribe.return_value._callback(event) + + +def create_rendering_control_event( + soco: MockSoCo, +) -> SonosMockEvent: + """Create a Sonos Event for speaker rendering control.""" + variables = { + "dialog_level": 1, + "speech_enhance_enable": 1, + "surround_level": 6, + "music_surround_level": 4, + "audio_delay": 0, + "audio_delay_left_rear": 0, + "audio_delay_right_rear": 0, + "night_mode": 0, + "surround_enabled": 1, + "surround_mode": 1, + "height_channel_level": 1, + } + return SonosMockEvent(soco, soco.renderingControl, variables) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 04457ee95c7..c7df2062b0f 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -6,13 +6,18 @@ from unittest.mock import patch import pytest -from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER +from homeassistant.components.sonos.const import ( + DATA_SONOS_DISCOVERY_MANAGER, + MODEL_SONOS_ARC_ULTRA, +) from homeassistant.components.sonos.switch import ( ATTR_DURATION, ATTR_ID, ATTR_INCLUDE_LINKED_ZONES, ATTR_PLAY_MODE, ATTR_RECURRENCE, + ATTR_SPEECH_ENHANCEMENT, + ATTR_SPEECH_ENHANCEMENT_ENABLED, ATTR_VOLUME, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -29,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent, create_rendering_control_event from tests.common import async_fire_time_changed @@ -142,6 +147,49 @@ async def test_switch_attributes( assert touch_controls_state.state == STATE_ON +@pytest.mark.parametrize( + ("model", "attribute"), + [ + ("Sonos One SL", ATTR_SPEECH_ENHANCEMENT), + (MODEL_SONOS_ARC_ULTRA.lower(), ATTR_SPEECH_ENHANCEMENT_ENABLED), + ], +) +async def test_switch_speech_enhancement( + hass: HomeAssistant, + async_setup_sonos, + soco: MockSoCo, + speaker_info: dict[str, str], + entity_registry: er.EntityRegistry, + model: str, + attribute: str, +) -> None: + """Tests the speech enhancement switch and attribute substitution for different models.""" + entity_id = "switch.zone_a_speech_enhancement" + speaker_info["model_name"] = model + soco.get_speaker_info.return_value = speaker_info + setattr(soco, attribute, True) + await async_setup_sonos() + switch = entity_registry.entities[entity_id] + state = hass.states.get(switch.entity_id) + assert state.state == STATE_ON + + event = create_rendering_control_event(soco) + event.variables[attribute] = False + soco.renderingControl.subscribe.return_value._callback(event) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(switch.entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert getattr(soco, attribute) is True + + @pytest.mark.parametrize( ("service", "expected_result"), [ From c4cb70fc064565871b7c82ad58eaeda428cc5ab3 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:12:18 +0200 Subject: [PATCH 1036/1118] Handle HusqvarnaWSClientError (#150145) --- homeassistant/components/husqvarna_automower/coordinator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index a037df474cc..dc35c47ff4a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -12,6 +12,7 @@ from aioautomower.exceptions import ( ApiError, AuthError, HusqvarnaTimeoutError, + HusqvarnaWSClientError, HusqvarnaWSServerHandshakeError, ) from aioautomower.model import MowerDictionary, MowerStates @@ -172,7 +173,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Reset reconnect time after successful connection self.reconnect_time = DEFAULT_RECONNECT_TIME await automower_client.start_listening() - except HusqvarnaWSServerHandshakeError as err: + except (HusqvarnaWSServerHandshakeError, HusqvarnaWSClientError) as err: _LOGGER.debug( "Failed to connect to websocket. Trying to reconnect: %s", err, From 5585376b406f099fb29a970b160877b57e5efcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 8 Aug 2025 22:13:23 +0200 Subject: [PATCH 1037/1118] Switchbot Hub Light level (#150147) --- homeassistant/components/switchbot/icons.json | 15 +++++++++++++++ homeassistant/components/switchbot/sensor.py | 1 - tests/components/switchbot/test_sensor.py | 12 +++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 2aef019aab4..cf9217bf70b 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,6 +1,21 @@ { "entity": { "sensor": { + "light_level": { + "default": "mdi:brightness-7", + "state": { + "1": "mdi:brightness-1", + "2": "mdi:brightness-1", + "3": "mdi:brightness-2", + "4": "mdi:brightness-3", + "5": "mdi:brightness-4", + "6": "mdi:brightness-5", + "7": "mdi:brightness-5", + "8": "mdi:brightness-6", + "9": "mdi:brightness-6", + "10": "mdi:brightness-7" + } + }, "water_level": { "default": "mdi:water-percent", "state": { diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index f6c5d526ab7..9196453e98c 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -67,7 +67,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { "lightLevel": SensorEntityDescription( key="lightLevel", translation_key="light_level", - native_unit_of_measurement="Level", state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 411d7282893..645eb5d1ab3 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -320,13 +320,12 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: light_level_sensor_attrs = light_level_sensor.attributes assert light_level_sensor.state == "4" assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" - light_level_sensor = hass.states.get("sensor.test_name_illuminance") - light_level_sensor_attrs = light_level_sensor.attributes - assert light_level_sensor.state == "30" - assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + illuminance_sensor = hass.states.get("sensor.test_name_illuminance") + illuminance_sensor_attrs = illuminance_sensor.attributes + assert illuminance_sensor.state == "30" + assert illuminance_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert illuminance_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") rssi_sensor_attrs = rssi_sensor.attributes @@ -474,7 +473,6 @@ async def test_hub3_sensor(hass: HomeAssistant) -> None: light_level_sensor_attrs = light_level_sensor.attributes assert light_level_sensor.state == "3" assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" - assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" assert light_level_sensor_attrs[ATTR_STATE_CLASS] == "measurement" illuminance_sensor = hass.states.get("sensor.test_name_illuminance") From 860a7b7d91f2aa8477f0db3b13e8dd619890c512 Mon Sep 17 00:00:00 2001 From: Marco Gasparini Date: Fri, 8 Aug 2025 22:29:50 +0200 Subject: [PATCH 1038/1118] Fix Progettihwsw config flow (#150149) --- homeassistant/components/progettihwsw/config_flow.py | 6 +++--- tests/components/progettihwsw/test_config_flow.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 8818eff2d81..826d5872d7c 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -30,9 +30,9 @@ async def validate_input(hass: HomeAssistant, data): return { "title": is_valid["title"], - "relay_count": is_valid["relay_count"], - "input_count": is_valid["input_count"], - "is_old": is_valid["is_old"], + "relay_count": is_valid["relays"], + "input_count": is_valid["inputs"], + "is_old": is_valid["temps"], } diff --git a/tests/components/progettihwsw/test_config_flow.py b/tests/components/progettihwsw/test_config_flow.py index 8dcc6917346..c41c88ec950 100644 --- a/tests/components/progettihwsw/test_config_flow.py +++ b/tests/components/progettihwsw/test_config_flow.py @@ -12,9 +12,9 @@ from tests.common import MockConfigEntry mock_value_step_user = { "title": "1R & 1IN Board", - "relay_count": 1, - "input_count": 1, - "is_old": False, + "relays": 1, + "inputs": 1, + "temps": False, } From 2d89c60ac5bbd6764b4668a05a809665069d3ef0 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:51:24 +0200 Subject: [PATCH 1039/1118] Improve service schemas in unifiprotect (#150236) --- .../components/unifiprotect/services.py | 50 +++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 708a4883ddd..5a3dcc6ddfd 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -60,43 +60,31 @@ ALL_GLOBAL_SERIVCES = [ SERVICE_GET_USER_KEYRING_INFO, ] -DOORBELL_TEXT_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - vol.Required(ATTR_MESSAGE): cv.string, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +DOORBELL_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_MESSAGE): cv.string, + }, ) -CHIME_PAIRED_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - "doorbells": cv.TARGET_SERVICE_FIELDS, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +CHIME_PAIRED_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + "doorbells": cv.ENTITY_SERVICE_FIELDS, + }, ) -REMOVE_PRIVACY_ZONE_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - vol.Required(ATTR_NAME): cv.string, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +REMOVE_PRIVACY_ZONE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_NAME): cv.string, + }, ) -GET_USER_KEYRING_INFO_SCHEMA = vol.All( - vol.Schema( - { - **cv.ENTITY_SERVICE_FIELDS, - }, - ), - cv.has_at_least_one_key(ATTR_DEVICE_ID), +GET_USER_KEYRING_INFO_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, ) From 5d2877f454b4393459dd4aea9f92b7b341c8e300 Mon Sep 17 00:00:00 2001 From: mbo18 Date: Fri, 8 Aug 2025 22:55:24 +0200 Subject: [PATCH 1040/1118] Add absolute humidity sensor to Awair integration (#150110) --- homeassistant/components/awair/const.py | 1 + homeassistant/components/awair/sensor.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 10f7cb115da..a7bb8a0c550 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging +API_ABS_HUMID = "abs_humid" API_CO2 = "carbon_dioxide" API_DEW_POINT = "dew_point" API_DUST = "dust" diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index d1f3ec34bf4..b0a44cb3e17 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_SW_VERSION, + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -33,6 +34,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + API_ABS_HUMID, API_CO2, API_DEW_POINT, API_DUST, @@ -120,6 +122,14 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + AwairSensorEntityDescription( + key=API_ABS_HUMID, + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, + native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, + unique_id_tag="absolute_humidity", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ) SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( From e585b3abd1e152805320445434fe4e2b4486f75f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 8 Aug 2025 23:33:55 +0200 Subject: [PATCH 1041/1118] Fix missing sentence-casing of "AC failure" in `bosch_alarm` (#150279) --- homeassistant/components/bosch_alarm/strings.json | 2 +- .../bosch_alarm/snapshots/test_binary_sensor.ambr | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 76c15a0a5c7..3adccda2ee5 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -95,7 +95,7 @@ "name": "Battery missing" }, "panel_fault_ac_fail": { - "name": "AC Failure" + "name": "AC failure" }, "panel_fault_parameter_crc_fail_in_pif": { "name": "CRC failure in panel configuration" diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr index e3444777ff0..7e1604127e2 100644 --- a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -182,7 +182,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch AMAX 3000 AC Failure', + 'friendly_name': 'Bosch AMAX 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', @@ -1187,7 +1187,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1201,7 +1201,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch B5512 (US1B) AC Failure', + 'friendly_name': 'Bosch B5512 (US1B) AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', @@ -2206,7 +2206,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2220,7 +2220,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch Solution 3000 AC Failure', + 'friendly_name': 'Bosch Solution 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', From b41a9575af97b3213a9880f6da36da3a3c4659c6 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 8 Aug 2025 23:58:19 +0200 Subject: [PATCH 1042/1118] Add protected call for data retrieval (#150035) --- homeassistant/components/bsblan/__init__.py | 43 +++++++++++++++++--- homeassistant/components/bsblan/strings.json | 14 +++++++ tests/components/bsblan/test_init.py | 38 ++++++++++++++++- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 623bfbfef56..a7beb4f8d44 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -2,7 +2,16 @@ import dataclasses -from bsblan import BSBLAN, BSBLANConfig, Device, Info, StaticState +from bsblan import ( + BSBLAN, + BSBLANAuthError, + BSBLANConfig, + BSBLANConnectionError, + BSBLANError, + Device, + Info, + StaticState, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,9 +22,14 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PASSKEY +from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -54,10 +68,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan) await coordinator.async_config_entry_first_refresh() - # Fetch all required data concurrently - device = await bsblan.device() - info = await bsblan.info() - static = await bsblan.static_values() + try: + # Fetch all required data sequentially + device = await bsblan.device() + info = await bsblan.info() + static = await bsblan.static_values() + except BSBLANConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + translation_placeholders={"host": entry.data[CONF_HOST]}, + ) from err + except BSBLANAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_auth_error", + ) from err + except BSBLANError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="setup_general_error", + ) from err entry.runtime_data = BSBLanData( client=bsblan, diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 86e52e76f41..b27be62e052 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -41,6 +41,11 @@ "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, @@ -66,6 +71,15 @@ }, "set_operation_mode_error": { "message": "An error occurred while setting the operation mode" + }, + "setup_connection_error": { + "message": "Failed to retrieve static device data from BSB-Lan device at {host}" + }, + "setup_auth_error": { + "message": "Authentication failed while retrieving static device data" + }, + "setup_general_error": { + "message": "An unknown error occurred while retrieving static device data" } }, "entity": { diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index cc52799d28b..10945a24878 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock -from bsblan import BSBLANAuthError, BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -75,3 +76,38 @@ async def test_config_entry_auth_failed_triggers_reauth( assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("method", "exception", "expected_state"), + [ + ( + "device", + BSBLANConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ( + "info", + BSBLANAuthError("Authentication failed"), + ConfigEntryState.SETUP_ERROR, + ), + ("static_values", BSBLANError("General error"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_static_data_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + method: str, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test various errors during static data fetching trigger appropriate config entry states.""" + # Mock the specified method to raise the exception + getattr(mock_bsblan, method).side_effect = exception + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state From c0bef5156360b8981be9f591ab923d09f23bf683 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Sat, 9 Aug 2025 00:01:39 +0200 Subject: [PATCH 1043/1118] Refactor airq tests to mock the API class in a fixture (#149712) --- tests/components/airq/conftest.py | 26 +++++++++++ tests/components/airq/test_config_flow.py | 56 +++++++++++------------ tests/components/airq/test_coordinator.py | 38 +++++++-------- 3 files changed, 68 insertions(+), 52 deletions(-) diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index a132153a76f..52d7fc77eb4 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest +from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +15,27 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.airq.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_airq(): + """Mock the aioairq.AirQ object. + + The integration imports it in two places: in coordinator and config_flow. + """ + + with ( + patch( + "homeassistant.components.airq.coordinator.AirQ", + autospec=True, + ) as mock_airq_class, + patch( + "homeassistant.components.airq.config_flow.AirQ", + new=mock_airq_class, + ), + ): + airq = mock_airq_class.return_value + # Pre-configure default mock values for setup + airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO) + airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA) + yield airq diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 95c22cb12c8..66cacecdaaa 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock from aioairq import InvalidAuth from aiohttp.client_exceptions import ClientConnectionError @@ -29,7 +29,11 @@ DEFAULT_OPTIONS = { } -async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: +async def test_form( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, +) -> None: """Test we get the form.""" caplog.set_level(logging.DEBUG) result = await hass.config_entries.flow.async_init( @@ -38,53 +42,49 @@ async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_USER_DATA, - ) - await hass.async_block_till_done() - assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_DATA, + ) + await hass.async_block_till_done() + assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] assert result2["data"] == TEST_USER_DATA -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=InvalidAuth): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} - ) + mock_airq.validate.side_effect = InvalidAuth + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=ClientConnectionError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + mock_airq.validate.side_effect = ClientConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_duplicate_error(hass: HomeAssistant) -> None: +async def test_duplicate_error(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( data=TEST_USER_DATA, @@ -96,13 +96,9 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py index 6512d60ddbe..f45986df61d 100644 --- a/tests/components/airq/test_coordinator.py +++ b/tests/components/airq/test_coordinator.py @@ -1,7 +1,7 @@ """Test the air-Q coordinator.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest @@ -32,7 +32,9 @@ STATUS_WARMUP = { async def test_logging_in_coordinator_first_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the first AirQCoordinator._async_update_data call logs necessary setup. @@ -48,11 +50,7 @@ async def test_logging_in_coordinator_first_update_data( assert "name" not in coordinator.device_info # First call: fetch missing device info - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the missing name is logged... assert ( @@ -71,7 +69,9 @@ async def test_logging_in_coordinator_first_update_data( async def test_logging_in_coordinator_subsequent_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the second AirQCoordinator._async_update_data call has nothing to log. @@ -83,11 +83,7 @@ async def test_logging_in_coordinator_subsequent_update_data( coordinator = AirQCoordinator(hass, MOCKED_ENTRY) coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO)) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the name _is not_ missing assert "name" in coordinator.device_info # and that nothing of the kind is logged @@ -102,19 +98,17 @@ async def test_logging_in_coordinator_subsequent_update_data( async def test_logging_when_warming_up_sensor_present( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that warming up sensors are logged.""" caplog.set_level(logging.DEBUG) coordinator = AirQCoordinator(hass, MOCKED_ENTRY) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch( - "aioairq.AirQ.get_latest_data", - return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP}, - ), - ): - await coordinator._async_update_data() + mock_airq.get_latest_data.return_value = TEST_DEVICE_DATA | { + "Status": STATUS_WARMUP + } + await coordinator._async_update_data() assert ( f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}" in caplog.text From f9e1c07c04332095f187547e911620fbbbb8e120 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:07:47 +0200 Subject: [PATCH 1044/1118] Add event platform to Husqvarna Automower (#148212) Co-authored-by: G Johansson --- .../husqvarna_automower/__init__.py | 1 + .../components/husqvarna_automower/const.py | 125 ++++++++ .../components/husqvarna_automower/event.py | 108 +++++++ .../components/husqvarna_automower/icons.json | 5 + .../components/husqvarna_automower/sensor.py | 128 +------- .../husqvarna_automower/strings.json | 152 +++++++++ .../snapshots/test_event.ambr | 303 ++++++++++++++++++ .../husqvarna_automower/test_event.py | 206 ++++++++++++ 8 files changed, 901 insertions(+), 127 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/event.py create mode 100644 tests/components/husqvarna_automower/snapshots/test_event.ambr create mode 100644 tests/components/husqvarna_automower/test_event.py diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 1945647a706..02adbc4adb6 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -21,6 +21,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CALENDAR, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.LAWN_MOWER, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index d91fea29698..f50c03e1b53 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -17,3 +17,128 @@ ERROR_STATES = [ MowerStates.WAIT_POWER_UP, MowerStates.WAIT_UPDATING, ] + +ERROR_KEYS = [ + "alarm_mower_in_motion", + "alarm_mower_lifted", + "alarm_mower_stopped", + "alarm_mower_switched_off", + "alarm_mower_tilted", + "alarm_outside_geofence", + "angular_sensor_problem", + "battery_problem", + "battery_restriction_due_to_ambient_temperature", + "can_error", + "charging_current_too_high", + "charging_station_blocked", + "charging_system_problem", + "collision_sensor_defect", + "collision_sensor_error", + "collision_sensor_problem_front", + "collision_sensor_problem_rear", + "com_board_not_available", + "communication_circuit_board_sw_must_be_updated", + "complex_working_area", + "connection_changed", + "connection_not_changed", + "connectivity_problem", + "connectivity_settings_restored", + "cutting_drive_motor_1_defect", + "cutting_drive_motor_2_defect", + "cutting_drive_motor_3_defect", + "cutting_height_blocked", + "cutting_height_problem", + "cutting_height_problem_curr", + "cutting_height_problem_dir", + "cutting_height_problem_drive", + "cutting_motor_problem", + "cutting_stopped_slope_too_steep", + "cutting_system_blocked", + "cutting_system_imbalance_warning", + "cutting_system_major_imbalance", + "destination_not_reachable", + "difficult_finding_home", + "docking_sensor_defect", + "electronic_problem", + "empty_battery", + "folding_cutting_deck_sensor_defect", + "folding_sensor_activated", + "geofence_problem", + "gps_navigation_problem", + "guide_1_not_found", + "guide_2_not_found", + "guide_3_not_found", + "guide_calibration_accomplished", + "guide_calibration_failed", + "high_charging_power_loss", + "high_internal_power_loss", + "high_internal_temperature", + "internal_voltage_error", + "invalid_battery_combination_invalid_combination_of_different_battery_types", + "invalid_sub_device_combination", + "invalid_system_configuration", + "left_brush_motor_overloaded", + "lift_sensor_defect", + "lifted", + "limited_cutting_height_range", + "loop_sensor_defect", + "loop_sensor_problem_front", + "loop_sensor_problem_left", + "loop_sensor_problem_rear", + "loop_sensor_problem_right", + "low_battery", + "memory_circuit_problem", + "mower_lifted", + "mower_tilted", + "no_accurate_position_from_satellites", + "no_confirmed_position", + "no_drive", + "no_loop_signal", + "no_power_in_charging_station", + "no_response_from_charger", + "outside_working_area", + "poor_signal_quality", + "reference_station_communication_problem", + "right_brush_motor_overloaded", + "safety_function_faulty", + "settings_restored", + "sim_card_locked", + "sim_card_not_found", + "sim_card_requires_pin", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", + "slope_too_steep", + "sms_could_not_be_sent", + "stop_button_problem", + "stuck_in_charging_station", + "switch_cord_problem", + "temporary_battery_problem", + "tilt_sensor_problem", + "too_high_discharge_current", + "too_high_internal_current", + "trapped", + "ultrasonic_problem", + "ultrasonic_sensor_1_defect", + "ultrasonic_sensor_2_defect", + "ultrasonic_sensor_3_defect", + "ultrasonic_sensor_4_defect", + "unexpected_cutting_height_adj", + "unexpected_error", + "upside_down", + "weak_gps_signal", + "wheel_drive_problem_left", + "wheel_drive_problem_rear_left", + "wheel_drive_problem_rear_right", + "wheel_drive_problem_right", + "wheel_motor_blocked_left", + "wheel_motor_blocked_rear_left", + "wheel_motor_blocked_rear_right", + "wheel_motor_blocked_right", + "wheel_motor_overloaded_left", + "wheel_motor_overloaded_rear_left", + "wheel_motor_overloaded_rear_right", + "wheel_motor_overloaded_right", + "work_area_not_valid", + "wrong_loop_signal", + "wrong_pin_code", + "zone_generator_problem", +] diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py new file mode 100644 index 00000000000..8e2e48b940d --- /dev/null +++ b/homeassistant/components/husqvarna_automower/event.py @@ -0,0 +1,108 @@ +"""Creates the event entities for supported mowers.""" + +from collections.abc import Callable + +from aioautomower.model import SingleMessageData + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AutomowerConfigEntry +from .const import ERROR_KEYS +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +PARALLEL_UPDATES = 1 + +ATTR_SEVERITY = "severity" +ATTR_LATITUDE = "latitude" +ATTR_LONGITUDE = "longitude" +ATTR_DATE_TIME = "date_time" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AutomowerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Automower message event entities. + + Entities are created dynamically based on messages received from the API, + but only for mowers that support message events. + """ + coordinator = config_entry.runtime_data + entity_registry = er.async_get(hass) + + restored_mowers = { + entry.unique_id.removesuffix("_message") + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entry.domain == EVENT_DOMAIN + } + + async_add_entities( + AutomowerMessageEventEntity(mower_id, coordinator) + for mower_id in restored_mowers + if mower_id in coordinator.data + ) + + @callback + def _handle_message(msg: SingleMessageData) -> None: + if msg.id in restored_mowers: + return + + restored_mowers.add(msg.id) + async_add_entities([AutomowerMessageEventEntity(msg.id, coordinator)]) + + coordinator.api.register_single_message_callback(_handle_message) + + +class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): + """EventEntity for Automower message events.""" + + entity_description: EventEntityDescription + _message_cb: Callable[[SingleMessageData], None] + _attr_translation_key = "message" + _attr_event_types = ERROR_KEYS + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize Automower message event entity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{mower_id}_message" + + @callback + def _handle(self, msg: SingleMessageData) -> None: + """Handle a message event from the API and trigger the event entity if it matches the entity's mower ID.""" + if msg.id != self.mower_id: + return + message = msg.attributes.message + self._trigger_event( + message.code, + { + ATTR_SEVERITY: message.severity, + ATTR_LATITUDE: message.latitude, + ATTR_LONGITUDE: message.longitude, + ATTR_DATE_TIME: message.time, + }, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callback when entity is added to hass.""" + await super().async_added_to_hass() + self.coordinator.api.register_single_message_callback(self._handle) + + async def async_will_remove_from_hass(self) -> None: + """Unregister WebSocket callback when entity is removed.""" + self.coordinator.api.unregister_single_message_callback(self._handle) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 5ff5940bdf4..ba9bc82f156 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -13,6 +13,11 @@ "default": "mdi:saw-blade" } }, + "event": { + "message": { + "default": "mdi:alert-circle-check-outline" + } + }, "number": { "cutting_height": { "default": "mdi:grass" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index c5af18c6387..50be89e9d42 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry -from .const import ERROR_STATES +from .const import ERROR_KEYS, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -42,132 +42,6 @@ PARALLEL_UPDATES = 0 ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEYS = [ - "alarm_mower_in_motion", - "alarm_mower_lifted", - "alarm_mower_stopped", - "alarm_mower_switched_off", - "alarm_mower_tilted", - "alarm_outside_geofence", - "angular_sensor_problem", - "battery_problem", - "battery_restriction_due_to_ambient_temperature", - "can_error", - "charging_current_too_high", - "charging_station_blocked", - "charging_system_problem", - "collision_sensor_defect", - "collision_sensor_error", - "collision_sensor_problem_front", - "collision_sensor_problem_rear", - "com_board_not_available", - "communication_circuit_board_sw_must_be_updated", - "complex_working_area", - "connection_changed", - "connection_not_changed", - "connectivity_problem", - "connectivity_settings_restored", - "cutting_drive_motor_1_defect", - "cutting_drive_motor_2_defect", - "cutting_drive_motor_3_defect", - "cutting_height_blocked", - "cutting_height_problem", - "cutting_height_problem_curr", - "cutting_height_problem_dir", - "cutting_height_problem_drive", - "cutting_motor_problem", - "cutting_stopped_slope_too_steep", - "cutting_system_blocked", - "cutting_system_imbalance_warning", - "cutting_system_major_imbalance", - "destination_not_reachable", - "difficult_finding_home", - "docking_sensor_defect", - "electronic_problem", - "empty_battery", - "folding_cutting_deck_sensor_defect", - "folding_sensor_activated", - "geofence_problem", - "gps_navigation_problem", - "guide_1_not_found", - "guide_2_not_found", - "guide_3_not_found", - "guide_calibration_accomplished", - "guide_calibration_failed", - "high_charging_power_loss", - "high_internal_power_loss", - "high_internal_temperature", - "internal_voltage_error", - "invalid_battery_combination_invalid_combination_of_different_battery_types", - "invalid_sub_device_combination", - "invalid_system_configuration", - "left_brush_motor_overloaded", - "lift_sensor_defect", - "lifted", - "limited_cutting_height_range", - "loop_sensor_defect", - "loop_sensor_problem_front", - "loop_sensor_problem_left", - "loop_sensor_problem_rear", - "loop_sensor_problem_right", - "low_battery", - "memory_circuit_problem", - "mower_lifted", - "mower_tilted", - "no_accurate_position_from_satellites", - "no_confirmed_position", - "no_drive", - "no_loop_signal", - "no_power_in_charging_station", - "no_response_from_charger", - "outside_working_area", - "poor_signal_quality", - "reference_station_communication_problem", - "right_brush_motor_overloaded", - "safety_function_faulty", - "settings_restored", - "sim_card_locked", - "sim_card_not_found", - "sim_card_requires_pin", - "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", - "slope_too_steep", - "sms_could_not_be_sent", - "stop_button_problem", - "stuck_in_charging_station", - "switch_cord_problem", - "temporary_battery_problem", - "tilt_sensor_problem", - "too_high_discharge_current", - "too_high_internal_current", - "trapped", - "ultrasonic_problem", - "ultrasonic_sensor_1_defect", - "ultrasonic_sensor_2_defect", - "ultrasonic_sensor_3_defect", - "ultrasonic_sensor_4_defect", - "unexpected_cutting_height_adj", - "unexpected_error", - "upside_down", - "weak_gps_signal", - "wheel_drive_problem_left", - "wheel_drive_problem_rear_left", - "wheel_drive_problem_rear_right", - "wheel_drive_problem_right", - "wheel_motor_blocked_left", - "wheel_motor_blocked_rear_left", - "wheel_motor_blocked_rear_right", - "wheel_motor_blocked_right", - "wheel_motor_overloaded_left", - "wheel_motor_overloaded_rear_left", - "wheel_motor_overloaded_rear_right", - "wheel_motor_overloaded_right", - "work_area_not_valid", - "wrong_loop_signal", - "wrong_pin_code", - "zone_generator_problem", -] - - ERROR_KEY_LIST = sorted( set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"} ) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index bd8a9346552..c10e56ec7c8 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -58,6 +58,158 @@ "name": "Reset cutting blade usage time" } }, + "event": { + "message": { + "name": "Message", + "state_attributes": { + "event_type": { + "state": { + "alarm_mower_in_motion": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_in_motion%]", + "alarm_mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_lifted%]", + "alarm_mower_stopped": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_stopped%]", + "alarm_mower_switched_off": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_switched_off%]", + "alarm_mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_tilted%]", + "alarm_outside_geofence": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_outside_geofence%]", + "angular_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::angular_sensor_problem%]", + "battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_problem%]", + "battery_restriction_due_to_ambient_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_restriction_due_to_ambient_temperature%]", + "can_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::can_error%]", + "charging_current_too_high": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_current_too_high%]", + "charging_station_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_station_blocked%]", + "charging_system_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_system_problem%]", + "collision_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_defect%]", + "collision_sensor_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_error%]", + "collision_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_front%]", + "collision_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_rear%]", + "com_board_not_available": "[%key:component::husqvarna_automower::entity::sensor::error::state::com_board_not_available%]", + "communication_circuit_board_sw_must_be_updated": "[%key:component::husqvarna_automower::entity::sensor::error::state::communication_circuit_board_sw_must_be_updated%]", + "complex_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::complex_working_area%]", + "connection_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_changed%]", + "connection_not_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_not_changed%]", + "connectivity_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_problem%]", + "connectivity_settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_settings_restored%]", + "cutting_drive_motor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_1_defect%]", + "cutting_drive_motor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_2_defect%]", + "cutting_drive_motor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_3_defect%]", + "cutting_height_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_blocked%]", + "cutting_height_problem_curr": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_curr%]", + "cutting_height_problem_dir": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_dir%]", + "cutting_height_problem_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_drive%]", + "cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]", + "cutting_motor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_motor_problem%]", + "cutting_stopped_slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_stopped_slope_too_steep%]", + "cutting_system_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_blocked%]", + "cutting_system_imbalance_warning": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_imbalance_warning%]", + "cutting_system_major_imbalance": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_major_imbalance%]", + "destination_not_reachable": "[%key:component::husqvarna_automower::entity::sensor::error::state::destination_not_reachable%]", + "difficult_finding_home": "[%key:component::husqvarna_automower::entity::sensor::error::state::difficult_finding_home%]", + "docking_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::docking_sensor_defect%]", + "electronic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::electronic_problem%]", + "empty_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::empty_battery%]", + "error_at_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::error_at_power_up%]", + "error": "[%key:common::state::error%]", + "fatal_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::fatal_error%]", + "folding_cutting_deck_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_cutting_deck_sensor_defect%]", + "folding_sensor_activated": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_sensor_activated%]", + "geofence_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::geofence_problem%]", + "gps_navigation_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::gps_navigation_problem%]", + "guide_1_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_1_not_found%]", + "guide_2_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_2_not_found%]", + "guide_3_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_3_not_found%]", + "guide_calibration_accomplished": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_accomplished%]", + "guide_calibration_failed": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_failed%]", + "high_charging_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_charging_power_loss%]", + "high_internal_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_power_loss%]", + "high_internal_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_temperature%]", + "internal_voltage_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::internal_voltage_error%]", + "invalid_battery_combination_invalid_combination_of_different_battery_types": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_battery_combination_invalid_combination_of_different_battery_types%]", + "invalid_sub_device_combination": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_sub_device_combination%]", + "invalid_system_configuration": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_system_configuration%]", + "left_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::left_brush_motor_overloaded%]", + "lift_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::lift_sensor_defect%]", + "lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::lifted%]", + "limited_cutting_height_range": "[%key:component::husqvarna_automower::entity::sensor::error::state::limited_cutting_height_range%]", + "loop_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_defect%]", + "loop_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_front%]", + "loop_sensor_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_left%]", + "loop_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_rear%]", + "loop_sensor_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_right%]", + "low_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::low_battery%]", + "memory_circuit_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::memory_circuit_problem%]", + "mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_lifted%]", + "mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_tilted%]", + "no_accurate_position_from_satellites": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_accurate_position_from_satellites%]", + "no_confirmed_position": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_confirmed_position%]", + "no_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_drive%]", + "no_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_error%]", + "no_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_loop_signal%]", + "no_power_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_power_in_charging_station%]", + "no_response_from_charger": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_response_from_charger%]", + "off": "[%key:common::state::off%]", + "outside_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::outside_working_area%]", + "poor_signal_quality": "[%key:component::husqvarna_automower::entity::sensor::error::state::poor_signal_quality%]", + "reference_station_communication_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::reference_station_communication_problem%]", + "right_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::right_brush_motor_overloaded%]", + "safety_function_faulty": "[%key:component::husqvarna_automower::entity::sensor::error::state::safety_function_faulty%]", + "settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::settings_restored%]", + "sim_card_locked": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_locked%]", + "sim_card_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_not_found%]", + "sim_card_requires_pin": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_requires_pin%]", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern": "[%key:component::husqvarna_automower::entity::sensor::error::state::slipped_mower_has_slipped_situation_not_solved_with_moving_pattern%]", + "slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::slope_too_steep%]", + "sms_could_not_be_sent": "[%key:component::husqvarna_automower::entity::sensor::error::state::sms_could_not_be_sent%]", + "stop_button_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::stop_button_problem%]", + "stopped": "[%key:common::state::stopped%]", + "stuck_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::stuck_in_charging_station%]", + "switch_cord_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::switch_cord_problem%]", + "temporary_battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::temporary_battery_problem%]", + "tilt_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::tilt_sensor_problem%]", + "too_high_discharge_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_discharge_current%]", + "too_high_internal_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_internal_current%]", + "trapped": "[%key:component::husqvarna_automower::entity::sensor::error::state::trapped%]", + "ultrasonic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_problem%]", + "ultrasonic_sensor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_1_defect%]", + "ultrasonic_sensor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_2_defect%]", + "ultrasonic_sensor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_3_defect%]", + "ultrasonic_sensor_4_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_4_defect%]", + "unexpected_cutting_height_adj": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_cutting_height_adj%]", + "unexpected_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_error%]", + "upside_down": "[%key:component::husqvarna_automower::entity::sensor::error::state::upside_down%]", + "wait_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_power_up%]", + "wait_updating": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_updating%]", + "weak_gps_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::weak_gps_signal%]", + "wheel_drive_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_left%]", + "wheel_drive_problem_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_left%]", + "wheel_drive_problem_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_right%]", + "wheel_drive_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_right%]", + "wheel_motor_blocked_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_left%]", + "wheel_motor_blocked_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_left%]", + "wheel_motor_blocked_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_right%]", + "wheel_motor_blocked_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_right%]", + "wheel_motor_overloaded_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_left%]", + "wheel_motor_overloaded_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_left%]", + "wheel_motor_overloaded_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_right%]", + "wheel_motor_overloaded_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_right%]", + "work_area_not_valid": "[%key:component::husqvarna_automower::entity::sensor::error::state::work_area_not_valid%]", + "wrong_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_loop_signal%]", + "wrong_pin_code": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_pin_code%]", + "zone_generator_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::zone_generator_problem%]" + } + }, + "severity": { + "state": { + "fatal": "Fatal", + "error": "[%key:common::state::error%]", + "warning": "Warning", + "info": "Info", + "debug": "Debug", + "sw": "Software", + "unknown": "Unknown" + } + } + } + } + }, "number": { "cutting_height": { "name": "Cutting height" diff --git a/tests/components/husqvarna_automower/snapshots/test_event.ambr b/tests/components/husqvarna_automower/snapshots/test_event.ambr new file mode 100644 index 00000000000..e01f8d04f2c --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_event.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_event_snapshot[event.test_mower_1_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_mower_1_message', + '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': 'Message', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.test_mower_1_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date_time': HAFakeDatetime(2025, 7, 13, 15, 30, tzinfo=datetime.timezone.utc), + 'event_type': 'wheel_motor_overloaded_rear_left', + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + 'friendly_name': 'Test Mower 1 Message', + 'latitude': 49.0, + 'longitude': 10.0, + 'severity': , + }), + 'context': , + 'entity_id': 'event.test_mower_1_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-05T12:00:00.000+00:00', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py new file mode 100644 index 00000000000..6cbfa102976 --- /dev/null +++ b/tests/components/husqvarna_automower/test_event.py @@ -0,0 +1,206 @@ +"""Tests for init module.""" + +from collections.abc import Callable +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from aioautomower.model import MowerAttributes, SingleMessageData +from aioautomower.model.model_message import Message, Severity, SingleMessageAttributes +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test that a new message arriving over the websocket creates and updates the sensor.""" + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Check initial state (event entity not available yet) + state = hass.states.get("event.test_mower_1_message") + assert state is None + + # Simulate a new message for this mower and check entity creation + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Reload the config entry to ensure the entity is created again + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Check updating event with a new message + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="alarm_mower_lifted", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + + # Check message for another mower, creates an new entity and dont + # change the state of the first entity + message = SingleMessageData( + type="messages", + id="1234", + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="battery_problem", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") + assert entry is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + state = hass.states.get("event.test_mower_2_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "battery_problem" + + # Check event entity is removed, when the mower is removed + values_copy = deepcopy(values) + values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_2_message") + assert state is None + entry = entity_registry.async_get("event.test_mower_2_message") + assert entry is None + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event_snapshot( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new message arriving over the websocket updates the sensor.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.EVENT], + ): + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Simulate a new message for this mower + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) From e9d39a826e7c52d0213ea4f6414a9a02b5e63c10 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Aug 2025 00:24:38 +0200 Subject: [PATCH 1045/1118] Remove deprecated horizontal vane select from Sensibo (#150108) --- homeassistant/components/sensibo/select.py | 62 +------------ homeassistant/components/sensibo/strings.json | 78 ++++++---------- tests/components/sensibo/test_select.py | 93 +------------------ 3 files changed, 33 insertions(+), 200 deletions(-) diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 5a0546b1aa2..1ed9a1bbefc 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,24 +8,11 @@ from typing import TYPE_CHECKING, Any from pysensibo.model import SensiboDevice -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.select import ( - DOMAIN as SELECT_DOMAIN, - SelectEntity, - SelectEntityDescription, -) +from homeassistant.components.select import SelectEntity, SelectEntityDescription 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 SensiboConfigEntry -from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -42,16 +29,6 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] -HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - entity_registry_enabled_default=False, -) - DEVICE_SELECT_TYPES = ( SensiboSelectEntityDescription( key="light", @@ -73,43 +50,6 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboSelect] = [] - - entity_registry = er.async_get(hass) - for device_id, device_data in coordinator.data.parsed.items(): - if entity_id := entity_registry.async_get_entity_id( - SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" - ): - entity = entity_registry.async_get(entity_id) - if entity and entity.disabled: - entity_registry.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - ) - elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: - entities.append( - SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) - ) - if automations_with_entity(hass, entity_id) or scripts_with_entity( - hass, entity_id - ): - async_create_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity_horizontalswing", - translation_placeholders={ - "name": str(entity.name or entity.original_name), - "entity": entity_id, - }, - ) - async_add_entities(entities) - added_devices: set[str] = set() def _add_remove_devices() -> None: diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 4dce104d1c7..1071a7739f6 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -77,22 +77,6 @@ } }, "select": { - "horizontalswing": { - "name": "Horizontal swing", - "state": { - "stopped": "[%key:common::state::off%]", - "fixedleft": "Fixed left", - "fixedcenterleft": "Fixed center left", - "fixedcenter": "Fixed center", - "fixedcenterright": "Fixed center right", - "fixedright": "Fixed right", - "fixedleftright": "Fixed left right", - "rangecenter": "Range center", - "rangefull": "Range full", - "rangeleft": "Range left", - "rangeright": "Range right" - } - }, "light": { "name": "[%key:component::light::title%]", "state": { @@ -153,14 +137,16 @@ "name": "Horizontal swing", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "Fixed left", + "fixedcenterleft": "Fixed center left", + "fixedcenter": "Fixed center", + "fixedcenterright": "Fixed center right", + "fixedright": "Fixed right", + "fixedleftright": "Fixed left right", + "rangecenter": "Range center", + "rangefull": "Range full", + "rangeleft": "Range left", + "rangeright": "Range right" } }, "light": { @@ -239,14 +225,14 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::name%]", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]" } }, "light": { @@ -383,7 +369,7 @@ "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } @@ -391,16 +377,16 @@ "swing_horizontal_mode": { "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", - "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", - "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeright%]" } } } @@ -590,11 +576,5 @@ "mode_not_exist": { "message": "The entity does not support the chosen mode" } - }, - "issues": { - "deprecated_entity_horizontalswing": { - "title": "The Sensibo {name} entity is deprecated", - "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 75dbdc88840..05a4fb731d1 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -14,16 +14,13 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er -from . import ENTRY_CONFIG - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -154,87 +151,3 @@ async def test_select_set_option( state = hass.states.get("select.kitchen_light") assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize( - "load_platforms", - [[Platform.SELECT]], -) -async def test_deprecated_horizontal_swing_select( - hass: HomeAssistant, - load_platforms: list[Platform], - mock_client: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the deprecated horizontal swing select entity.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - entry_id="1", - unique_id="firstnamelastname", - version=2, - ) - - config_entry.add_to_hass(hass) - - entity_registry.async_get_or_create( - SELECT_DOMAIN, - DOMAIN, - "ABC999111-horizontalSwing", - config_entry=config_entry, - disabled_by=None, - has_entity_name=True, - suggested_object_id="hallway_horizontal_swing", - ) - - with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" - - # No issue created without automation or script - assert issue_registry.issues == {} - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - # Patch check for automation, that one exist - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Issue is created when entity is enabled and automation/script exist - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert issue - assert issue.translation_key == "deprecated_entity_horizontalswing" - assert hass.states.get("select.hallway_horizontal_swing") - assert entity_registry.async_is_registered("select.hallway_horizontal_swing") - - # Disabling the entity should remove the entity and remove the issue - # once the integration is reloaded - entity_registry.async_update_entity( - state.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Disabling the entity and reloading has removed the entity and issue - assert not hass.states.get("select.hallway_horizontal_swing") - assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert not issue From c876bed33f8a1b91c5290e8912c2cefdc66bf9d2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 9 Aug 2025 00:24:54 +0200 Subject: [PATCH 1046/1118] Add ToGrill integration (#150075) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/togrill/__init__.py | 33 + .../components/togrill/config_flow.py | 136 ++++ homeassistant/components/togrill/const.py | 8 + .../components/togrill/coordinator.py | 148 ++++ homeassistant/components/togrill/entity.py | 18 + .../components/togrill/manifest.json | 18 + .../components/togrill/quality_scale.yaml | 68 ++ homeassistant/components/togrill/sensor.py | 127 ++++ homeassistant/components/togrill/strings.json | 32 + homeassistant/generated/bluetooth.py | 6 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/togrill/__init__.py | 40 ++ tests/components/togrill/conftest.py | 96 +++ .../togrill/snapshots/test_sensor.ambr | 673 ++++++++++++++++++ tests/components/togrill/test_config_flow.py | 155 ++++ tests/components/togrill/test_init.py | 60 ++ tests/components/togrill/test_sensor.py | 59 ++ 21 files changed, 1692 insertions(+) create mode 100644 homeassistant/components/togrill/__init__.py create mode 100644 homeassistant/components/togrill/config_flow.py create mode 100644 homeassistant/components/togrill/const.py create mode 100644 homeassistant/components/togrill/coordinator.py create mode 100644 homeassistant/components/togrill/entity.py create mode 100644 homeassistant/components/togrill/manifest.json create mode 100644 homeassistant/components/togrill/quality_scale.yaml create mode 100644 homeassistant/components/togrill/sensor.py create mode 100644 homeassistant/components/togrill/strings.json create mode 100644 tests/components/togrill/__init__.py create mode 100644 tests/components/togrill/conftest.py create mode 100644 tests/components/togrill/snapshots/test_sensor.ambr create mode 100644 tests/components/togrill/test_config_flow.py create mode 100644 tests/components/togrill/test_init.py create mode 100644 tests/components/togrill/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d52349d49e8..9a7b961748c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1597,6 +1597,8 @@ build.json @home-assistant/supervisor /tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl +/homeassistant/components/togrill/ @elupus +/tests/components/togrill/ @elupus /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr /homeassistant/components/tomorrowio/ @raman325 @lymanepp diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py new file mode 100644 index 00000000000..e938c56b9ee --- /dev/null +++ b/homeassistant/components/togrill/__init__.py @@ -0,0 +1,33 @@ +"""The ToGrill integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Set up ToGrill Bluetooth from a config entry.""" + + coordinator = ToGrillCoordinator(hass, entry) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as exc: + if not isinstance(exc.__cause__, DeviceNotFound): + raise + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py new file mode 100644 index 00000000000..29d930e7961 --- /dev/null +++ b/homeassistant/components/togrill/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the ToGrill integration.""" + +from __future__ import annotations + +from typing import Any + +from bleak.exc import BleakError +from togrill_bluetooth import SUPPORTED_DEVICES +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import PacketA0Notify +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow + +from .const import CONF_PROBE_COUNT, DOMAIN +from .coordinator import LOGGER + +_TIMEOUT = 10 + + +async def read_config_data( + hass: HomeAssistant, info: BluetoothServiceInfoBleak +) -> dict[str, Any]: + """Read config from device.""" + + try: + client = await Client.connect(info.device) + except BleakError as exc: + LOGGER.debug("Failed to connect", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + + try: + packet_a0 = await client.read(PacketA0Notify) + except BleakError as exc: + LOGGER.debug("Failed to read data", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + finally: + await client.disconnect() + + return { + CONF_MODEL: info.name, + CONF_ADDRESS: info.address, + CONF_PROBE_COUNT: packet_a0.probe_count, + } + + +class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ToGrillBluetooth.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovery_infos: dict[str, BluetoothServiceInfoBleak] = {} + + async def _async_create_entry_internal( + self, info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + config_data = await read_config_data(self.hass, info) + + return self.async_create_entry( + title=config_data[CONF_MODEL], + data=config_data, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + if discovery_info.name not in SUPPORTED_DEVICES: + return self.async_abort(reason="not_supported") + + self._discovery_info = discovery_info + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + assert self._discovery_info is not None + discovery_info = self._discovery_info + + if user_input is not None: + return await self._async_create_entry_internal(discovery_info) + + self._set_confirm_only() + placeholders = {"name": discovery_info.name} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return await self._async_create_entry_internal( + self._discovery_infos[address] + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, True): + address = discovery_info.address + if ( + address in current_addresses + or address in self._discovery_infos + or discovery_info.name not in SUPPORTED_DEVICES + ): + continue + self._discovery_infos[address] = discovery_info + + if not self._discovery_infos: + return self.async_abort(reason="no_devices_found") + + addresses = {info.address: info.name for info in self._discovery_infos.values()} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) diff --git a/homeassistant/components/togrill/const.py b/homeassistant/components/togrill/const.py new file mode 100644 index 00000000000..dd2fe820919 --- /dev/null +++ b/homeassistant/components/togrill/const.py @@ -0,0 +1,8 @@ +"""Constants for the ToGrill integration.""" + +DOMAIN = "togrill" + +MAX_PROBE_COUNT = 6 + +CONF_PROBE_COUNT = "probe_count" +CONF_VERSION = "version" diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py new file mode 100644 index 00000000000..b79e4350e1e --- /dev/null +++ b/homeassistant/components/togrill/coordinator.py @@ -0,0 +1,148 @@ +"""Coordinator for the ToGrill Bluetooth integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import DecodeError +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_register_callback, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] + +SCAN_INTERVAL = timedelta(seconds=30) +LOGGER = logging.getLogger(__name__) + + +def get_version_string(packet: PacketA0Notify) -> str: + """Construct a version string from packet data.""" + return f"{packet.version_major}.{packet.version_minor}" + + +class DeviceNotFound(UpdateFailed): + """Update failed due to device disconnected.""" + + +class DeviceFailed(UpdateFailed): + """Update failed due to device disconnected.""" + + +class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): + """Class to manage fetching data.""" + + config_entry: ToGrillConfigEntry + client: Client | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: ToGrillConfigEntry, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + config_entry=config_entry, + name="ToGrill", + update_interval=SCAN_INTERVAL, + ) + self.address = config_entry.data[CONF_ADDRESS] + self.data = {} + self.device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, self.address)} + ) + + config_entry.async_on_unload( + async_register_callback( + hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address, connectable=True), + BluetoothScanningMode.ACTIVE, + ) + ) + + async def _connect_and_update_registry(self) -> Client: + """Update device registry data.""" + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) + if not device: + raise DeviceNotFound("Unable to find device") + + client = await Client.connect(device, self._notify_callback) + try: + packet_a0 = await client.read(PacketA0Notify) + except (BleakError, DecodeError) as exc: + await client.disconnect() + raise DeviceFailed(f"Device failed {exc}") from exc + + config_entry = self.config_entry + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_BLUETOOTH, self.address)}, + name=config_entry.data[CONF_MODEL], + model=config_entry.data[CONF_MODEL], + sw_version=get_version_string(packet_a0), + ) + + return client + + async def async_shutdown(self) -> None: + """Shutdown coordinator and disconnect from device.""" + await super().async_shutdown() + if self.client: + await self.client.disconnect() + self.client = None + + async def _get_connected_client(self) -> Client: + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + if self.client: + return self.client + + self.client = await self._connect_and_update_registry() + return self.client + + def _notify_callback(self, packet: Packet): + self.data[packet.type] = packet + self.async_update_listeners() + + async def _async_update_data(self) -> dict[int, Packet]: + """Poll the device.""" + client = await self._get_connected_client() + try: + await client.request(PacketA0Notify) + await client.request(PacketA1Notify) + except BleakError as exc: + raise DeviceFailed(f"Device failed {exc}") from exc + return self.data + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + if not self.client and isinstance(self.last_exception, DeviceNotFound): + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py new file mode 100644 index 00000000000..c1a254557c5 --- /dev/null +++ b/homeassistant/components/togrill/entity.py @@ -0,0 +1,18 @@ +"""Provides the base entities.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ToGrillCoordinator + + +class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ToGrillCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json new file mode 100644 index 00000000000..7d777b8ae67 --- /dev/null +++ b/homeassistant/components/togrill/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "togrill", + "name": "ToGrill", + "bluetooth": [ + { + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + "connectable": true + } + ], + "codeowners": ["@elupus"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/togrill", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["togrill-bluetooth==0.4.0"] +} diff --git a/homeassistant/components/togrill/quality_scale.yaml b/homeassistant/components/togrill/quality_scale.yaml new file mode 100644 index 00000000000..6dd44090f80 --- /dev/null +++ b/homeassistant/components/togrill/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-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: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration only has a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration only has a single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not need any websession + strict-typing: todo diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py new file mode 100644 index 00000000000..7298e4b971b --- /dev/null +++ b/homeassistant/components/togrill/sensor.py @@ -0,0 +1,127 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, cast + +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ToGrillSensorEntityDescription(SensorEntityDescription): + """Description of entity.""" + + packet_type: int + packet_extract: Callable[[Packet], StateType] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_description(probe_number: int): + def _get(packet: Packet) -> StateType: + assert isinstance(packet, PacketA1Notify) + if len(packet.temperatures) < probe_number: + return None + temperature = packet.temperatures[probe_number - 1] + if temperature is None: + return None + return temperature + + def _supported(config: Mapping[str, Any]): + return probe_number <= config[CONF_PROBE_COUNT] + + return ToGrillSensorEntityDescription( + key=f"temperature_{probe_number}", + translation_key="temperature", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + packet_type=PacketA1Notify.type, + packet_extract=_get, + entity_supported=_supported, + ) + + +ENTITY_DESCRIPTIONS = ( + ToGrillSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + packet_type=PacketA0Notify.type, + packet_extract=lambda packet: cast(PacketA0Notify, packet).battery, + ), + *[ + _get_temperature_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillSensor(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillSensor(ToGrillEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: ToGrillSensorEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillSensorEntityDescription, + ) -> None: + """Initialize sensor.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.native_value is not None + + @property + def native_value(self) -> StateType: + """Get current value.""" + if packet := self.coordinator.data.get(self.entity_description.packet_type): + return self.entity_description.packet_extract(packet) + return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json new file mode 100644 index 00000000000..1b75e387221 --- /dev/null +++ b/homeassistant/components/togrill/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select the device to add." + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "failed_to_read_config": "Failed to read config from device" + } + }, + "entity": { + "sensor": { + "temperature": { + "name": "Probe {probe_number}" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index da6cab4bc22..fcaa824ff39 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -834,6 +834,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ ], "manufacturer_id": 76, }, + { + "connectable": True, + "domain": "togrill", + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8de75b21bba..823bd339d51 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -653,6 +653,7 @@ FLOWS = { "tilt_pi", "time_date", "todoist", + "togrill", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e9a8f46a496..d40f882240b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6790,6 +6790,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "togrill": { + "name": "ToGrill", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "tolo": { "name": "TOLO Sauna", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 2cdb87b5ad2..a876b41b8db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2955,6 +2955,9 @@ tmb==0.0.4 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9dfd138977c..7059e5691ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2432,6 +2432,9 @@ tilt-pi==0.2.1 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/tests/components/togrill/__init__.py b/tests/components/togrill/__init__.py new file mode 100644 index 00000000000..9e0d164ae2a --- /dev/null +++ b/tests/components/togrill/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the ToGrill Bluetooth integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TOGRILL_SERVICE_INFO = BluetoothServiceInfo( + name="Pro-05", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + +TOGRILL_SERVICE_INFO_NO_NAME = BluetoothServiceInfo( + name="", + address="00000000-0000-0000-0000-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +async def setup_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Make sure the device is available.""" + + with patch("homeassistant.components.togrill._PLATFORMS", platforms): + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py new file mode 100644 index 00000000000..6b028ca5270 --- /dev/null +++ b/tests/components/togrill/conftest.py @@ -0,0 +1,96 @@ +"""Common fixtures for the ToGrill tests.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketNotify + +from homeassistant.components.togrill.const import CONF_PROBE_COUNT, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_MODEL + +from . import TOGRILL_SERVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create hass config fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: TOGRILL_SERVICE_INFO.address, + CONF_MODEL: "Pro-05", + CONF_PROBE_COUNT: 2, + }, + unique_id=TOGRILL_SERVICE_INFO.address, + ) + + +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.togrill.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.togrill.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mock]: + """Auto mock bluetooth.""" + + client_object = Mock(spec=Client) + client_object.mocked_notify = None + + async def _connect( + address: str, callback: Callable[[Packet], None] | None = None + ) -> Mock: + client_object.mocked_notify = callback + return client_object + + async def _disconnect() -> None: + pass + + async def _request(packet_type: type[Packet]) -> None: + if packet_type is PacketA0Notify: + client_object.mocked_notify(PacketA0Notify(0, 0, 0, 0, 0, False, 0, False)) + + async def _read(packet_type: type[PacketNotify]) -> PacketNotify: + if packet_type is PacketA0Notify: + return PacketA0Notify(0, 0, 0, 0, 0, False, 0, False) + raise NotImplementedError + + mock_client_class.connect.side_effect = _connect + client_object.request.side_effect = _request + client_object.read.side_effect = _read + client_object.disconnect.side_effect = _disconnect + client_object.is_connected = True + + return client_object + + +@pytest.fixture(autouse=True) +def mock_client_class() -> Generator[Mock]: + """Auto mock bluetooth.""" + + with ( + patch( + "homeassistant.components.togrill.config_flow.Client", autospec=True + ) as client_class, + patch("homeassistant.components.togrill.coordinator.Client", new=client_class), + ): + yield client_class diff --git a/tests/components/togrill/snapshots/test_sensor.ambr b/tests/components/togrill/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bc55d831500 --- /dev/null +++ b/tests/components/togrill/snapshots/test_sensor.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_setup[battery][sensor.pro_05_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.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[battery][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-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.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-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.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_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.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-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.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-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.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_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.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-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.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-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.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_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.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-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.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-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.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/togrill/test_config_flow.py b/tests/components/togrill/test_config_flow.py new file mode 100644 index 00000000000..2620a88f7f2 --- /dev/null +++ b/tests/components/togrill/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test the ToGrill config flow.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.togrill.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import TOGRILL_SERVICE_INFO, TOGRILL_SERVICE_INFO_NO_NAME, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_selection( + hass: HomeAssistant, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + await hass.async_block_till_done(wait_background_tasks=True) + + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address + + +async def test_failed_connect( + hass: HomeAssistant, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test failure to connect result.""" + + mock_client_class.connect.side_effect = BleakError("Failed to connect") + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + 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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_failed_read( + hass: HomeAssistant, + mock_client: Mock, +) -> None: + """Test failure to read from device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + 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" + + mock_client.read.side_effect = BleakError("something went wrong") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_no_devices( + hass: HomeAssistant, +) -> None: + """Test missing device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_duplicate_setup( + hass: HomeAssistant, + mock_entry: MockConfigEntry, +) -> None: + """Test we can not setup a device again.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + await setup_entry(hass, mock_entry, []) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth( + hass: HomeAssistant, +) -> None: + """Test bluetooth device discovery.""" + + # Inject the service info will trigger the flow to start + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) + + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address diff --git a/tests/components/togrill/test_init.py b/tests/components/togrill/test_init.py new file mode 100644 index 00000000000..24f19ba367e --- /dev/null +++ b/tests/components/togrill/test_init.py @@ -0,0 +1,60 @@ +"""Test for initialization of ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_setup_device_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup works with device present.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_not_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup succeeds if device is missing.""" + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_failing( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup fails if device is not responding.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + mock_client.is_connected = False + mock_client.read.side_effect = BleakError("Failed to read data") + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py new file mode 100644 index 00000000000..d7662d483af --- /dev/null +++ b/tests/components/togrill/test_sensor.py @@ -0,0 +1,59 @@ +"""Test sensors for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ) + ], + id="battery", + ), + pytest.param([PacketA1Notify([10, None])], id="temp_data"), + pytest.param([PacketA1Notify([10])], id="temp_data_missing_probe"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the sensors.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) From 1af0282091a53c2ecb3c890f07944f219fff9bea Mon Sep 17 00:00:00 2001 From: MB901 <80067777+MB901@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:54:53 +0200 Subject: [PATCH 1047/1118] Add hardware version to FreeboxRouter device info (#150004) --- homeassistant/components/freebox/router.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index d6c45cd178b..8ba7d88d938 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -117,6 +117,7 @@ class FreeboxRouter: self.name: str = freebox_config["model_info"]["pretty_name"] self.mac: str = freebox_config["mac"] self._sw_v: str = freebox_config["firmware_version"] + self._hw_v: str | None = freebox_config.get("board_name") self._attrs: dict[str, Any] = {} self.supports_hosts = True @@ -282,7 +283,9 @@ class FreeboxRouter: identifiers={(DOMAIN, self.mac)}, manufacturer="Freebox SAS", name=self.name, + model=self.name, sw_version=self._sw_v, + hw_version=self._hw_v, ) @property From 775701133d340df2bd7b3643600e3f21a9231ed1 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 9 Aug 2025 00:17:48 +0100 Subject: [PATCH 1048/1118] Remove deprecated notify platform from Mastodon (#149735) --- homeassistant/components/mastodon/__init__.py | 21 +-- homeassistant/components/mastodon/notify.py | 152 ------------------ .../components/mastodon/quality_scale.yaml | 16 +- .../components/mastodon/strings.json | 6 - tests/components/mastodon/test_notify.py | 65 -------- 5 files changed, 7 insertions(+), 253 deletions(-) delete mode 100644 homeassistant/components/mastodon/notify.py delete mode 100644 tests/components/mastodon/test_notify.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 17b8614a2e9..b6e0d863471 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -8,12 +8,11 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_NAME, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -22,7 +21,7 @@ from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client -PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -53,26 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry.runtime_data = MastodonData(client, instance, account, coordinator) - await discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: entry.title, "client": client}, - {}, - ) - - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py deleted file mode 100644 index 149ef1f6a48..00000000000 --- a/homeassistant/components/mastodon/notify.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Mastodon platform for notify component.""" - -from __future__ import annotations - -from typing import Any, cast - -from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError, MediaAttachment -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_DATA, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import ( - ATTR_CONTENT_WARNING, - ATTR_MEDIA_WARNING, - CONF_BASE_URL, - DEFAULT_URL, - DOMAIN, -) -from .utils import get_media_type - -ATTR_MEDIA = "media" -ATTR_TARGET = "target" - -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_BASE_URL, default=DEFAULT_URL): cv.string, - } -) - -INTEGRATION_TITLE = "Mastodon" - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> MastodonNotificationService | None: - """Get the Mastodon notification service.""" - if discovery_info is None: - return None - - client = cast(Mastodon, discovery_info.get("client")) - - return MastodonNotificationService(hass, client) - - -class MastodonNotificationService(BaseNotificationService): - """Implement the notification service for Mastodon.""" - - def __init__( - self, - hass: HomeAssistant, - client: Mastodon, - ) -> None: - """Initialize the service.""" - - self.client = client - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Toot a message, with media perhaps.""" - - ir.create_issue( - self.hass, - DOMAIN, - "deprecated_notify_action_mastodon", - breaks_in_ha_version="2025.9.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_notify_action", - ) - - target = None - if (target_list := kwargs.get(ATTR_TARGET)) is not None: - target = cast(list[str], target_list)[0] - - data = kwargs.get(ATTR_DATA) - - media = None - mediadata = None - sensitive = False - content_warning = None - - if data: - media = data.get(ATTR_MEDIA) - if media: - if not self.hass.config.is_allowed_path(media): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="not_whitelisted_directory", - translation_placeholders={"media": media}, - ) - mediadata = self._upload_media(media) - - sensitive = data.get(ATTR_MEDIA_WARNING) - content_warning = data.get(ATTR_CONTENT_WARNING) - - if mediadata: - try: - self.client.status_post( - message, - visibility=target, - spoiler_text=content_warning, - media_ids=mediadata.id, - sensitive=sensitive, - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - else: - try: - self.client.status_post( - message, visibility=target, spoiler_text=content_warning - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - def _upload_media(self, media_path: Any = None) -> MediaAttachment: - """Upload media.""" - with open(media_path, "rb"): - media_type = get_media_type(media_path) - try: - mediadata: MediaAttachment = self.client.media_post( - media_path, mime_type=media_type - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_upload_image", - translation_placeholders={"media_path": media_path}, - ) from err - - return mediadata diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index f07f7e0a8ad..c5a928bac59 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -26,10 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - Awaiting legacy Notify deprecation. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -39,19 +36,12 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: | - Awaiting legacy Notify deprecation. + parallel-updates: done reauthentication-flow: status: todo comment: | Waiting to move to oAuth. - test-coverage: - status: todo - comment: | - Awaiting legacy Notify deprecation. - + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9e6cf6db6bf..c37f9b2e941 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -42,12 +42,6 @@ "message": "{media} is not a whitelisted directory." } }, - "issues": { - "deprecated_notify_action": { - "title": "Deprecated Notify action used for Mastodon", - "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." - } - }, "entity": { "sensor": { "followers": { diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py deleted file mode 100644 index 4242f88d34a..00000000000 --- a/tests/components/mastodon/test_notify.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for the Mastodon notify platform.""" - -from unittest.mock import AsyncMock - -from mastodon.Mastodon import MastodonAPIError -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry - - -async def test_notify( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test sending a message.""" - await setup_integration(hass, mock_config_entry) - - assert hass.services.has_service(NOTIFY_DOMAIN, "trwnh_mastodon_social") - - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - ) - - assert mock_mastodon_client.status_post.assert_called_once - - -async def test_notify_failed( - hass: HomeAssistant, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the notify raising an error.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_mastodon_client.status_post.side_effect = MastodonAPIError - - with pytest.raises(HomeAssistantError, match="Unable to send message"): - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - ) From 73be4625aecde017bb8c235efb522e7249fc6240 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 9 Aug 2025 07:43:51 +0200 Subject: [PATCH 1049/1118] Add sensor uom suggestions to airOS (#150303) --- homeassistant/components/airos/sensor.py | 9 +++++ .../airos/snapshots/test_sensor.ambr | 39 +++++++++++++------ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 4567261ba4d..7b834b9c8a7 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -46,6 +46,7 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( translation_key="host_cpuload", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, value_fn=lambda data: data.host.cpuload, entity_registry_enabled_default=False, ), @@ -83,6 +84,8 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.tx, ), AirOSSensorEntityDescription( @@ -91,6 +94,8 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.throughput.rx, ), AirOSSensorEntityDescription( @@ -99,6 +104,8 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.dl_capacity, ), AirOSSensorEntityDescription( @@ -107,6 +114,8 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, value_fn=lambda data: data.wireless.polling.ul_capacity, ), ) diff --git a/tests/components/airos/snapshots/test_sensor.ambr b/tests/components/airos/snapshots/test_sensor.ambr index e414d35beb2..133b0f7f6e6 100644 --- a/tests/components/airos/snapshots/test_sensor.ambr +++ b/tests/components/airos/snapshots/test_sensor.ambr @@ -76,6 +76,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': None, 'original_icon': None, @@ -131,6 +134,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -141,7 +147,7 @@ 'supported_features': 0, 'translation_key': 'wireless_polling_dl_capacity', 'unique_id': '01:23:45:67:89:AB_wireless_polling_dl_capacity', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_download_capacity-state] @@ -150,14 +156,14 @@ 'device_class': 'data_rate', 'friendly_name': 'NanoStation 5AC ap name Download capacity', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_download_capacity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '647400', + 'state': '647.4', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_network_role-entry] @@ -245,6 +251,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -255,7 +264,7 @@ 'supported_features': 0, 'translation_key': 'wireless_throughput_rx', 'unique_id': '01:23:45:67:89:AB_wireless_throughput_rx', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_receive_actual-state] @@ -264,14 +273,14 @@ 'device_class': 'data_rate', 'friendly_name': 'NanoStation 5AC ap name Throughput receive (actual)', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_receive_actual', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '9907', + 'state': '9.907', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-entry] @@ -301,6 +310,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -311,7 +323,7 @@ 'supported_features': 0, 'translation_key': 'wireless_throughput_tx', 'unique_id': '01:23:45:67:89:AB_wireless_throughput_tx', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_throughput_transmit_actual-state] @@ -320,14 +332,14 @@ 'device_class': 'data_rate', 'friendly_name': 'NanoStation 5AC ap name Throughput transmit (actual)', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_throughput_transmit_actual', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '222', + 'state': '0.222', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-entry] @@ -357,6 +369,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -367,7 +382,7 @@ 'supported_features': 0, 'translation_key': 'wireless_polling_ul_capacity', 'unique_id': '01:23:45:67:89:AB_wireless_polling_ul_capacity', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_upload_capacity-state] @@ -376,14 +391,14 @@ 'device_class': 'data_rate', 'friendly_name': 'NanoStation 5AC ap name Upload capacity', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.nanostation_5ac_ap_name_upload_capacity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '540540', + 'state': '540.54', }) # --- # name: test_all_entities[sensor.nanostation_5ac_ap_name_wireless_frequency-entry] From 5c1d16d582911f3780b1147fd27852cfb9af819b Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Aug 2025 07:44:35 +0200 Subject: [PATCH 1050/1118] Abort config flow if user has no friends in PlayStation Network (#150301) --- .../playstation_network/config_flow.py | 4 ++++ .../playstation_network/strings.json | 3 ++- .../playstation_network/test_config_flow.py | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index d4822225c61..d7d82292378 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -202,6 +202,9 @@ class FriendSubentryFlowHandler(ConfigSubentryFlow): } ) + if not self.friends_list: + return self.async_abort(reason="no_friends") + options = [ SelectOptionDict( value=friend.account_id, @@ -209,6 +212,7 @@ class FriendSubentryFlowHandler(ConfigSubentryFlow): ) for friend in self.friends_list.values() ] + return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 26a1b336e2d..15b83b7cd0d 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -69,7 +69,8 @@ }, "abort": { "already_configured_as_entry": "Already configured as a service. This account cannot be added as a friend.", - "already_configured": "Already configured as a friend in this or another account." + "already_configured": "Already configured as a friend in this or another account.", + "no_friends": "Looks like your friend list is empty right now. Add friends on PlayStation Network first." } } }, diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 4194f1fb258..0cd94fe153a 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -493,3 +493,27 @@ async def test_add_friend_flow_already_configured_as_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_as_entry" + + +async def test_add_friend_flow_no_friends( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test we abort add friend subentry flow when the user has no friends.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.return_value.friends_list.return_value = [] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "friend"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_friends" From 586b197fc39493812ffe8294dbb32aa602c91c84 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 9 Aug 2025 07:46:48 +0200 Subject: [PATCH 1051/1118] Speedup Tuya snapshot tests (#150198) --- tests/components/tuya/__init__.py | 9 +- tests/components/tuya/conftest.py | 19 +- .../snapshots/test_alarm_control_panel.ambr | 4 +- .../tuya/snapshots/test_binary_sensor.ambr | 660 +- .../tuya/snapshots/test_button.ambr | 20 +- .../tuya/snapshots/test_camera.ambr | 218 +- .../tuya/snapshots/test_climate.ambr | 190 +- .../components/tuya/snapshots/test_cover.ambr | 318 +- .../components/tuya/snapshots/test_event.ambr | 8 +- tests/components/tuya/snapshots/test_fan.ambr | 456 +- .../tuya/snapshots/test_humidifier.ambr | 126 +- .../components/tuya/snapshots/test_light.ambr | 3714 ++++----- .../tuya/snapshots/test_number.ambr | 2042 ++--- .../tuya/snapshots/test_select.ambr | 2270 +++--- .../tuya/snapshots/test_sensor.ambr | 6836 ++++++++--------- .../components/tuya/snapshots/test_siren.ambr | 106 +- .../tuya/snapshots/test_switch.ambr | 6190 +++++++-------- .../tuya/snapshots/test_vacuum.ambr | 4 +- .../tuya/test_alarm_control_panel.py | 31 +- tests/components/tuya/test_binary_sensor.py | 30 +- tests/components/tuya/test_button.py | 31 +- tests/components/tuya/test_camera.py | 30 +- tests/components/tuya/test_climate.py | 30 +- tests/components/tuya/test_cover.py | 30 +- tests/components/tuya/test_event.py | 31 +- tests/components/tuya/test_fan.py | 29 +- tests/components/tuya/test_humidifier.py | 29 +- tests/components/tuya/test_light.py | 30 +- tests/components/tuya/test_number.py | 28 +- tests/components/tuya/test_select.py | 28 +- tests/components/tuya/test_sensor.py | 28 +- tests/components/tuya/test_siren.py | 29 +- tests/components/tuya/test_switch.py | 29 +- tests/components/tuya/test_vacuum.py | 30 +- 34 files changed, 11652 insertions(+), 12011 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index e4bdffd73b6..249bed68c90 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -532,13 +532,14 @@ async def initialize_entry( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: CustomerDevice | list[CustomerDevice], ) -> None: """Initialize the Tuya component with a mock manager and config entry.""" + if not isinstance(mock_devices, list): + mock_devices = [mock_devices] + mock_manager.device_map = {device.id: device for device in mock_devices} + # Setup - mock_manager.device_map = { - mock_device.id: mock_device, - } mock_config_entry.add_to_hass(hass) # Initialize the component diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index b563e7d5241..59a6d6c27bd 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util -from . import MockDeviceListener +from . import DEVICE_MOCKS, MockDeviceListener from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -138,8 +138,25 @@ def mock_device_code() -> str: return None +@pytest.fixture +async def mock_devices(hass: HomeAssistant) -> list[CustomerDevice]: + """Load all Tuya CustomerDevice fixtures. + + Use this to generate global snapshots for each platform. + """ + return [await _create_device(hass, key) for key in DEVICE_MOCKS] + + @pytest.fixture async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice: + """Load a single Tuya CustomerDevice fixture. + + Use this for testing behavior on a specific device. + """ + return await _create_device(hass, mock_device_code) + + +async def _create_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice: """Mock a Tuya CustomerDevice.""" details = await async_load_json_object_fixture( hass, f"{mock_device_code}.json", DOMAIN diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr index 38c7f04f9d9..337b579c7da 100644 --- a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][alarm_control_panel.multifunction_alarm-entry] +# name: test_platform_setup_and_discovery[alarm_control_panel.multifunction_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][alarm_control_panel.multifunction_alarm-state] +# name: test_platform_setup_and_discovery[alarm_control_panel.multifunction_alarm-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 210ede6da09..d26bcac6d6d 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][binary_sensor.aqi_safety-entry] +# name: test_platform_setup_and_discovery[binary_sensor.aqi_safety-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][binary_sensor.aqi_safety-state] +# name: test_platform_setup_and_discovery[binary_sensor.aqi_safety-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'safety', @@ -48,7 +48,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_defrost-entry] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -83,7 +83,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_defrost-state] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -97,7 +97,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_tank_full-entry] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_tank_full-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -132,7 +132,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][binary_sensor.dehumidifer_tank_full-state] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_tank_full-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -146,7 +146,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_defrost-entry] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -181,7 +181,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_defrost-state] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_defrost-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -195,7 +195,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_tank_full-entry] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_tank_full-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -230,7 +230,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_tank_full-state] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_tank_full-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -244,7 +244,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_wet-entry] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_wet-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -279,7 +279,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][binary_sensor.dehumidifier_wet-state] +# name: test_platform_setup_and_discovery[binary_sensor.dehumidifier_wet-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -293,56 +293,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][binary_sensor.human_presence_office_occupancy-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.human_presence_office_occupancy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Occupancy', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.kxwleaa2sphpresence_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][binary_sensor.human_presence_office_occupancy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'occupancy', - 'friendly_name': 'Human presence Office Occupancy', - }), - 'context': , - 'entity_id': 'binary_sensor.human_presence_office_occupancy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][binary_sensor.door_garage_door-entry] +# name: test_platform_setup_and_discovery[binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -377,7 +328,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][binary_sensor.door_garage_door-state] +# name: test_platform_setup_and_discovery[binary_sensor.door_garage_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -391,252 +342,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_motion-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.rat_trap_hedge_motion', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Motion', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.hkm4px9ohzozxma3rippir', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_motion-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'motion', - 'friendly_name': 'rat trap hedge Motion', - }), - 'context': , - 'entity_id': 'binary_sensor.rat_trap_hedge_motion', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_tamper-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.rat_trap_hedge_tamper', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tamper', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.hkm4px9ohzozxma3riptemper_alarm', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][binary_sensor.rat_trap_hedge_tamper-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'tamper', - 'friendly_name': 'rat trap hedge Tamper', - }), - 'context': , - 'entity_id': 'binary_sensor.rat_trap_hedge_tamper', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_motion-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.motion_sensor_lidl_zigbee_motion', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Motion', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.s3zzjdcfrippir', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_motion-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'motion', - 'friendly_name': 'Motion sensor lidl zigbee Motion', - }), - 'context': , - 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_motion', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_tamper-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.motion_sensor_lidl_zigbee_tamper', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Tamper', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.s3zzjdcfriptemper_alarm', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][binary_sensor.motion_sensor_lidl_zigbee_tamper-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'tamper', - 'friendly_name': 'Motion sensor lidl zigbee Tamper', - }), - 'context': , - 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][binary_sensor.pir_outside_stairs_motion-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.pir_outside_stairs_motion', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Motion', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.zoytcemodrn39zqwrippir', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][binary_sensor.pir_outside_stairs_motion-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'motion', - 'friendly_name': 'PIR outside stairs Motion', - }), - 'context': , - 'entity_id': 'binary_sensor.pir_outside_stairs_motion', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][binary_sensor.gas_sensor_gas-entry] +# name: test_platform_setup_and_discovery[binary_sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -671,7 +377,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][binary_sensor.gas_sensor_gas-state] +# name: test_platform_setup_and_discovery[binary_sensor.gas_sensor_gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', @@ -685,7 +391,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[sj_tgvtvdoc][binary_sensor.tournesol_moisture-entry] +# name: test_platform_setup_and_discovery[binary_sensor.human_presence_office_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -698,7 +404,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.tournesol_moisture', + 'entity_id': 'binary_sensor.human_presence_office_occupancy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -708,33 +414,82 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Moisture', + 'original_name': 'Occupancy', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.codvtvgtjswatersensor_state', + 'unique_id': 'tuya.kxwleaa2sphpresence_state', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sj_tgvtvdoc][binary_sensor.tournesol_moisture-state] +# name: test_platform_setup_and_discovery[binary_sensor.human_presence_office_occupancy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'moisture', - 'friendly_name': 'Tournesol Moisture', + 'device_class': 'occupancy', + 'friendly_name': 'Human presence Office Occupancy', }), 'context': , - 'entity_id': 'binary_sensor.tournesol_moisture', + 'entity_id': 'binary_sensor.human_presence_office_occupancy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-entry] +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_motion-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.motion_sensor_lidl_zigbee_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.s3zzjdcfrippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Motion sensor lidl zigbee Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_tamper-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -747,7 +502,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -757,33 +512,180 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Problem', + 'original_name': 'Tamper', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.orotles4ucq8rxwn2gwmaster_state', + 'unique_id': 'tuya.s3zzjdcfriptemper_alarm', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wg2_nwxr8qcu4seltoro][binary_sensor.x5_zigbee_gateway_problem-state] +# name: test_platform_setup_and_discovery[binary_sensor.motion_sensor_lidl_zigbee_tamper-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'X5 Zigbee Gateway Problem', + 'device_class': 'tamper', + 'friendly_name': 'Motion sensor lidl zigbee Tamper', }), 'context': , - 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'entity_id': 'binary_sensor.motion_sensor_lidl_zigbee_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.pir_outside_stairs_motion-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.pir_outside_stairs_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.zoytcemodrn39zqwrippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.pir_outside_stairs_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'PIR outside stairs Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.pir_outside_stairs_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_motion-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.rat_trap_hedge_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hkm4px9ohzozxma3rippir', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'rat trap hedge Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_tamper-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.rat_trap_hedge_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hkm4px9ohzozxma3riptemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rat_trap_hedge_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'rat trap hedge Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.rat_trap_hedge_tamper', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-entry] +# name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -818,7 +720,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][binary_sensor.smoke_detector_upstairs_smoke-state] +# name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', @@ -832,3 +734,101 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.tournesol_moisture-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.tournesol_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.codvtvgtjswatersensor_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.tournesol_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Tournesol Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.tournesol_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-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.x5_zigbee_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.orotles4ucq8rxwn2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'X5 Zigbee Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.x5_zigbee_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_button.ambr b/tests/components/tuya/snapshots/test_button.ambr index d7a6d7fa401..6103a07d08d 100644 --- a/tests/components/tuya/snapshots/test_button.ambr +++ b/tests/components/tuya/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-entry] +# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_duster_cloth-state] +# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'V20 Reset duster cloth', @@ -47,7 +47,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-entry] +# name: test_platform_setup_and_discovery[button.v20_reset_edge_brush-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -82,7 +82,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_edge_brush-state] +# name: test_platform_setup_and_discovery[button.v20_reset_edge_brush-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'V20 Reset edge brush', @@ -95,7 +95,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-entry] +# name: test_platform_setup_and_discovery[button.v20_reset_filter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -130,7 +130,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_filter-state] +# name: test_platform_setup_and_discovery[button.v20_reset_filter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'V20 Reset filter', @@ -143,7 +143,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-entry] +# name: test_platform_setup_and_discovery[button.v20_reset_map-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -178,7 +178,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_map-state] +# name: test_platform_setup_and_discovery[button.v20_reset_map-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'V20 Reset map', @@ -191,7 +191,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-entry] +# name: test_platform_setup_and_discovery[button.v20_reset_roll_brush-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -226,7 +226,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][button.v20_reset_roll_brush-state] +# name: test_platform_setup_and_discovery[button.v20_reset_roll_brush-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'V20 Reset roll brush', diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr index f2ad466fdd2..b1ec2191850 100644 --- a/tests/components/tuya/snapshots/test_camera.ambr +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -1,112 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'camera', - 'entity_category': None, - 'entity_id': 'camera.cam_garage', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.mgcpxpmovasazerdps', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][camera.cam_garage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'access_token': '1', - 'brand': 'Tuya', - 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', - 'friendly_name': 'CAM GARAGE', - 'model_name': 'Indoor camera ', - 'motion_detection': True, - 'supported_features': , - }), - 'context': , - 'entity_id': 'camera.cam_garage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'idle', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'camera', - 'entity_category': None, - 'entity_id': 'camera.cam_porch', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrps', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][camera.cam_porch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'access_token': '1', - 'brand': 'Tuya', - 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', - 'friendly_name': 'CAM PORCH', - 'model_name': 'Indoor cam Pan/Tilt ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'camera.cam_porch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'idle', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-entry] +# name: test_platform_setup_and_discovery[camera.c9-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -141,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][camera.c9-state] +# name: test_platform_setup_and_discovery[camera.c9-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'access_token': '1', @@ -160,3 +53,110 @@ 'state': 'recording', }) # --- +# name: test_platform_setup_and_discovery[camera.cam_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mgcpxpmovasazerdps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_garage?token=1', + 'friendly_name': 'CAM GARAGE', + 'model_name': 'Indoor camera ', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_porch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.cam_porch', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.cam_porch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.cam_porch?token=1', + 'friendly_name': 'CAM PORCH', + 'model_name': 'Indoor cam Pan/Tilt ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.cam_porch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 6f8ffafc7a6..8353db8a8e1 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[kt_5wnlzekkstwcdsvm][climate.air_conditioner-entry] +# name: test_platform_setup_and_discovery[climate.air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -46,7 +46,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kt_5wnlzekkstwcdsvm][climate.air_conditioner-state] +# name: test_platform_setup_and_discovery[climate.air_conditioner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 22.0, @@ -74,82 +74,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][climate.kabinet-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 95.0, - 'min_temp': 5.0, - 'preset_modes': list([ - 'program', - ]), - '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.kabinet', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.dn7cjik6kw', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][climate.kabinet-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': 19.5, - 'friendly_name': 'Кабінет', - 'hvac_modes': list([ - , - , - , - ]), - 'max_temp': 95.0, - 'min_temp': 5.0, - 'preset_mode': None, - 'preset_modes': list([ - 'program', - ]), - 'supported_features': , - 'target_temp_step': 0.5, - 'temperature': 21.5, - }), - 'context': , - 'entity_id': 'climate.kabinet', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heat_cool', - }) -# --- -# name: test_platform_setup_and_discovery[wk_aqoouq7x][climate.clima_cucina-entry] +# name: test_platform_setup_and_discovery[climate.clima_cucina-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -199,7 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_aqoouq7x][climate.clima_cucina-state] +# name: test_platform_setup_and_discovery[climate.clima_cucina-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 27.0, @@ -230,7 +155,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][climate.wifi_smart_gas_boiler_thermostat-entry] +# name: test_platform_setup_and_discovery[climate.kabinet-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -239,9 +164,13 @@ 'hvac_modes': list([ , , + , ]), - 'max_temp': 35.0, + 'max_temp': 95.0, 'min_temp': 5.0, + 'preset_modes': list([ + 'program', + ]), 'target_temp_step': 0.5, }), 'config_entry_id': , @@ -251,7 +180,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'entity_id': 'climate.kabinet', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -267,36 +196,41 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.j6mn1t4ut5end6ifkw', + 'unique_id': 'tuya.dn7cjik6kw', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][climate.wifi_smart_gas_boiler_thermostat-state] +# name: test_platform_setup_and_discovery[climate.kabinet-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 24.9, - 'friendly_name': 'WiFi Smart Gas Boiler Thermostat ', + 'current_temperature': 19.5, + 'friendly_name': 'Кабінет', 'hvac_modes': list([ , , + , ]), - 'max_temp': 35.0, + 'max_temp': 95.0, 'min_temp': 5.0, - 'supported_features': , + 'preset_mode': None, + 'preset_modes': list([ + 'program', + ]), + 'supported_features': , 'target_temp_step': 0.5, - 'temperature': 22.0, + 'temperature': 21.5, }), 'context': , - 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'entity_id': 'climate.kabinet', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'heat_cool', }) # --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][climate.smart_thermostats-entry] +# name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -340,7 +274,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][climate.smart_thermostats-state] +# name: test_platform_setup_and_discovery[climate.smart_thermostats-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.5, @@ -364,7 +298,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][climate.term_prizemi-entry] +# name: test_platform_setup_and_discovery[climate.term_prizemi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -407,7 +341,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][climate.term_prizemi-state] +# name: test_platform_setup_and_discovery[climate.term_prizemi-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 23.0, @@ -430,3 +364,69 @@ 'state': 'heat_cool', }) # --- +# name: test_platform_setup_and_discovery[climate.wifi_smart_gas_boiler_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + '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.wifi_smart_gas_boiler_thermostat', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.j6mn1t4ut5end6ifkw', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.wifi_smart_gas_boiler_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.9, + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat ', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 560c4cd58ff..3266a5f6597 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,158 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-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.lounge_dark_blind_curtain', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Curtain', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': 'curtain', - 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][cover.lounge_dark_blind_curtain-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_position': 100, - 'device_class': 'curtain', - 'friendly_name': 'Lounge Dark Blind Curtain', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.lounge_dark_blind_curtain', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'open', - }) -# --- -# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-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.blinds_curtain', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Curtain', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': 'curtain', - 'unique_id': 'tuya.nr26obpclccontrol', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cl_cpbo62rn][cover.blinds_curtain-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_position': 36, - 'device_class': 'curtain', - 'friendly_name': 'blinds Curtain', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.blinds_curtain', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'open', - }) -# --- -# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-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.kitchen_blinds_blind', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Blind', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': 'blind', - 'unique_id': 'tuya.ftvxinxevpy21tbelcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cl_ebt12ypvexnixvtf][cover.kitchen_blinds_blind-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_position': 100, - 'device_class': 'blind', - 'friendly_name': 'Kitchen Blinds Blind', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.kitchen_blinds_blind', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'open', - }) -# --- -# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cover.bedroom_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -187,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cl_qqdxfdht][cover.bedroom_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cover.bedroom_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 0, @@ -203,7 +50,109 @@ 'state': 'closed', }) # --- -# name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cover.blinds_curtain-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.blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.nr26obpclccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 36, + 'device_class': 'curtain', + 'friendly_name': 'blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-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.kitchen_blinds_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blind', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'blind', + 'unique_id': 'tuya.ftvxinxevpy21tbelcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'blind', + 'friendly_name': 'Kitchen Blinds Blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -238,7 +187,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cl_zah67ekd][cover.kitchen_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cover.kitchen_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 48, @@ -254,7 +203,58 @@ 'state': 'open', }) # --- -# name: test_platform_setup_and_discovery[clkg_nhyj64w2][cover.tapparelle_studio_curtain-entry] +# name: test_platform_setup_and_discovery[cover.lounge_dark_blind_curtain-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.lounge_dark_blind_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.lounge_dark_blind_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'curtain', + 'friendly_name': 'Lounge Dark Blind Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.lounge_dark_blind_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_platform_setup_and_discovery[cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -289,7 +289,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[clkg_nhyj64w2][cover.tapparelle_studio_curtain-state] +# name: test_platform_setup_and_discovery[cover.tapparelle_studio_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 0, diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index f5c03f9d7a3..8e2afbdb9de 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_1-entry] +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_1-state] +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'button', @@ -58,7 +58,7 @@ 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_2-entry] +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,7 +98,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][event.bathroom_smart_switch_button_2-state] +# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'button', diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 57fa3f1e345..4efda28459e 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -1,11 +1,12 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][fan.dehumidifer-entry] +# name: test_platform_setup_and_discovery[fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ + 'sleep', ]), }), 'config_entry_id': , @@ -15,7 +16,7 @@ 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.dehumidifer', + 'entity_id': 'fan.bree', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -31,179 +32,31 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, - 'unique_id': 'tuya.ifzgvpgoodrfw2aksc', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjk', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][fan.dehumidifer-state] +# name: test_platform_setup_and_discovery[fan.bree-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifer', + 'friendly_name': 'Bree', + 'preset_mode': 'normal', 'preset_modes': list([ + 'sleep', ]), - 'supported_features': , + 'supported_features': , }), 'context': , - 'entity_id': 'fan.dehumidifer', + 'entity_id': 'fan.bree', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dryfix', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.hz4pau766eavmxhqsc', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][fan.dryfix-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'DryFix', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dryfix', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.ilms5pwjzzsxuxmvsc', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.2myxayqtud9aqbizsc', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-entry] +# name: test_platform_setup_and_discovery[fan.ceiling_fan_with_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -244,7 +97,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][fan.ceiling_fan_with_light-state] +# name: test_platform_setup_and_discovery[fan.ceiling_fan_with_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'direction': 'reverse', @@ -267,7 +120,61 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-entry] +# name: test_platform_setup_and_discovery[fan.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifer', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.ifzgvpgoodrfw2aksc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer', + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -281,7 +188,7 @@ 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.ventilador_cama', + 'entity_id': 'fan.dehumidifier', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -299,25 +206,125 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.c1tfgunpf6optybisf', + 'unique_id': 'tuya.ilms5pwjzzsxuxmvsc', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[fs_ibytpo6fpnugft1c][fan.ventilador_cama-state] +# name: test_platform_setup_and_discovery[fan.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ventilador Cama', + 'friendly_name': 'Dehumidifier ', 'supported_features': , }), 'context': , - 'entity_id': 'fan.ventilador_cama', + 'entity_id': 'fan.dehumidifier', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-entry] +# name: test_platform_setup_and_discovery[fan.dehumidifier_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.2myxayqtud9aqbizsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.dehumidifier_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[fan.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dryfix', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hz4pau766eavmxhqsc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'DryFix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[fan.hl400-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -355,7 +362,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][fan.hl400-state] +# name: test_platform_setup_and_discovery[fan.hl400-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'HL400', @@ -374,64 +381,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'preset_modes': list([ - 'sleep', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.bree', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.ppgdpsq1xaxlyzryjk', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][fan.bree-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Bree', - 'preset_mode': 'normal', - 'preset_modes': list([ - 'sleep', - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.bree', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][fan.tower_fan_ca_407g_smart-entry] +# name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -472,7 +422,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][fan.tower_fan_ca_407g_smart-state] +# name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Tower Fan CA-407G Smart', @@ -495,3 +445,53 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[fan.ventilador_cama-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ventilador_cama', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.c1tfgunpf6optybisf', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.ventilador_cama-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ventilador Cama', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ventilador_cama', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index c58d06e6888..ab172241bfa 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][humidifier.dehumidifer-entry] +# name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -37,7 +37,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][humidifier.dehumidifer-state] +# name: test_platform_setup_and_discovery[humidifier.dehumidifer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'dehumidifier', @@ -54,62 +54,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_humidity': 100, - 'min_humidity': 0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'humidifier', - 'entity_category': None, - 'entity_id': 'humidifier.dryfix', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.hz4pau766eavmxhqscswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_qhxmvae667uap4zh][humidifier.dryfix-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'dehumidifier', - 'friendly_name': 'DryFix', - 'max_humidity': 100, - 'min_humidity': 0, - 'supported_features': , - }), - 'context': , - 'entity_id': 'humidifier.dryfix', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-entry] +# name: test_platform_setup_and_discovery[humidifier.dehumidifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -147,7 +92,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_vmxuxszzjwp5smli][humidifier.dehumidifier-state] +# name: test_platform_setup_and_discovery[humidifier.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'dehumidifier', @@ -164,7 +109,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][humidifier.dehumidifier-entry] +# name: test_platform_setup_and_discovery[humidifier.dehumidifier_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -180,7 +125,7 @@ 'disabled_by': None, 'domain': 'humidifier', 'entity_category': None, - 'entity_id': 'humidifier.dehumidifier', + 'entity_id': 'humidifier.dehumidifier_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -202,7 +147,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][humidifier.dehumidifier-state] +# name: test_platform_setup_and_discovery[humidifier.dehumidifier_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 47, @@ -214,10 +159,65 @@ 'supported_features': , }), 'context': , - 'entity_id': 'humidifier.dehumidifier', + 'entity_id': 'humidifier.dehumidifier_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[humidifier.dryfix-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dryfix', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.hz4pau766eavmxhqscswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.dryfix-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'DryFix', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dryfix', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index e27ead0f022..d4dcd12cbb3 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -1,889 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[clkg_nhyj64w2][light.tapparelle_studio_backlight-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': , - 'entity_id': 'light.tapparelle_studio_backlight', - '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': 'Backlight', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'backlight', - 'unique_id': 'tuya.2w46jyhngklcswitch_backlight', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[clkg_nhyj64w2][light.tapparelle_studio_backlight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'color_mode': , - 'friendly_name': 'Tapparelle studio Backlight', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.tapparelle_studio_backlight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dc_l3bpgg8ibsagon4x][light.lsc_party_string_light_rgbic_cct-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.lsc_party_string_light_rgbic_cct', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.x4nogasbi8ggpb3lcdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dc_l3bpgg8ibsagon4x][light.lsc_party_string_light_rgbic_cct-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LSC Party String Light RGBIC+CCT ', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.lsc_party_string_light_rgbic_cct', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_8szt7whdvwpmxglk][light.porch_light_e-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.porch_light_e', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.klgxmpwvdhw7tzs8jdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_8szt7whdvwpmxglk][light.porch_light_e-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Porch light E', - 'hs_color': None, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.porch_light_e', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_8y0aquaa8v6tho8w][light.dressoir_spot-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.dressoir_spot', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.w8oht6v8aauqa0y8jdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_8y0aquaa8v6tho8w][light.dressoir_spot-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'dressoir spot', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.dressoir_spot', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_baf9tt9lb8t5uc7z][light.pokerlamp_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.pokerlamp_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.z7cu5t8bl9tt9fabjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_baf9tt9lb8t5uc7z][light.pokerlamp_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Pokerlamp 2', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.pokerlamp_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_d4g0fbsoaal841o6][light.wc_d1-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.wc_d1', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.6o148laaosbf0g4djdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_d4g0fbsoaal841o6][light.wc_d1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'WC D1', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.wc_d1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_djnozmdyqyriow8z][light.fakkel_8-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.fakkel_8', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.z8woiryqydmzonjdjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_djnozmdyqyriow8z][light.fakkel_8-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 70, - 'color_mode': , - 'color_temp': 500, - 'color_temp_kelvin': 2000, - 'friendly_name': 'Fakkel 8', - 'hs_color': tuple( - 30.601, - 94.547, - ), - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': tuple( - 255, - 137, - 14, - ), - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': tuple( - 0.598, - 0.383, - ), - }), - 'context': , - 'entity_id': 'light.fakkel_8', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dj_ekwolitfjhxn55js][light.ab6-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.ab6', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.sj55nxhjftilowkejdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_ekwolitfjhxn55js][light.ab6-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'ab6', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.ab6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_fuupmcr2mb1odkja][light.slaapkamer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.slaapkamer', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.ajkdo1bm2rcmpuufjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_fuupmcr2mb1odkja][light.slaapkamer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Slaapkamer', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.slaapkamer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_hp6orhaqm6as3jnv][light.master_bedroom_tv_lights-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.master_bedroom_tv_lights', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.vnj3sa6mqahro6phjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_hp6orhaqm6as3jnv][light.master_bedroom_tv_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 51, - 'color_mode': , - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Master bedroom TV lights', - 'hs_color': tuple( - 26.072, - 100.0, - ), - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': tuple( - 255, - 111, - 0, - ), - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': tuple( - 0.632, - 0.358, - ), - }), - 'context': , - 'entity_id': 'light.master_bedroom_tv_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.garage', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Garage', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.garage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage_light_1-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.garage_light_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': 'Light 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_light', - 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_hpc8ddyfv85haxa7][light.garage_light_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'color_mode': None, - 'friendly_name': 'Garage Light 1', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.garage_light_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_iayz2jmtlipjnxj7][light.led_porch_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.led_porch_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.7jxnjpiltmj2zyaijdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_iayz2jmtlipjnxj7][light.led_porch_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LED Porch 2', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.led_porch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_idnfq7xbx8qewyoa][light.ab1-entry] +# name: test_platform_setup_and_discovery[light.ab1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -927,7 +43,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_idnfq7xbx8qewyoa][light.ab1-state] +# name: test_platform_setup_and_discovery[light.ab1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': 255, @@ -966,88 +82,7 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[dj_ilddqqih3tucdk68][light.ieskas-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.ieskas', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.86kdcut3hiqqddlijdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_ilddqqih3tucdk68][light.ieskas-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'color_temp': 285, - 'color_temp_kelvin': 3508, - 'friendly_name': 'Ieskas', - 'hs_color': tuple( - 27.165, - 44.6, - ), - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': tuple( - 255, - 193, - 141, - ), - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - 'xy_color': tuple( - 0.453, - 0.374, - ), - }), - 'context': , - 'entity_id': 'light.ieskas', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dj_j1bgp31cffutizub][light.ceiling_portal-entry] +# name: test_platform_setup_and_discovery[light.ab6-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1069,7 +104,7 @@ 'disabled_by': None, 'domain': 'light', 'entity_category': None, - 'entity_id': 'light.ceiling_portal', + 'entity_id': 'light.ab6', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1087,174 +122,33 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.buzituffc13pgb1jjdswitch_led', + 'unique_id': 'tuya.sj55nxhjftilowkejdswitch_led', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_j1bgp31cffutizub][light.ceiling_portal-state] +# name: test_platform_setup_and_discovery[light.ab6-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Ceiling Portal', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.ceiling_portal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_lmnt3uyltk1xffrt][light.directietkamer-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ + 'friendly_name': 'ab6', 'max_color_temp_kelvin': 6500, 'max_mireds': 500, 'min_color_temp_kelvin': 2000, '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.directietkamer', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.trffx1ktlyu3tnmljdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_lmnt3uyltk1xffrt][light.directietkamer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'DirectietKamer', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , + , ]), 'supported_features': , - 'xy_color': None, }), 'context': , - 'entity_id': 'light.directietkamer', + 'entity_id': 'light.ab6', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[dj_mki13ie507rlry4r][light.garage_light-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.garage_light', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.r4yrlr705ei31ikmjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_mki13ie507rlry4r][light.garage_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 138, - 'color_mode': , - 'friendly_name': 'Garage light', - 'hs_color': None, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.garage_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dj_nbumqpv8vz61enji][light.b2-entry] +# name: test_platform_setup_and_discovery[light.b2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1298,7 +192,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_nbumqpv8vz61enji][light.b2-state] +# name: test_platform_setup_and_discovery[light.b2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'b2', @@ -1320,18 +214,14 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[dj_nlxvjzy1hoeiqsg6][light.hall-entry] +# name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, 'supported_color_modes': list([ - , + , ]), }), 'config_entry_id': , @@ -1340,8 +230,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.hall', + 'entity_category': , + 'entity_id': 'light.cam_garage_indicator_light', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1353,212 +243,42 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Indicator light', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.6gsqieoh1yzjvxlnjdswitch_led', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_indicator', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_nlxvjzy1hoeiqsg6][light.hall-state] +# name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'hall 💡 ', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, + 'color_mode': , + 'friendly_name': 'CAM GARAGE Indicator light', 'supported_color_modes': list([ - , + , ]), 'supported_features': , - 'xy_color': None, }), 'context': , - 'entity_id': 'light.hall', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_oe0cpnjg][light.front_right_lighting_trap-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.front_right_lighting_trap', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.gjnpc0eojdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_oe0cpnjg][light.front_right_lighting_trap-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Front right Lighting trap', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.front_right_lighting_trap', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dj_riwp3k79][light.led_keuken_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.led_keuken_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.97k3pwirjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_riwp3k79][light.led_keuken_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'color_temp': 500, - 'color_temp_kelvin': 2000, - 'friendly_name': 'LED KEUKEN 2', - 'hs_color': tuple( - 30.601, - 94.547, - ), - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': tuple( - 255, - 137, - 14, - ), - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': tuple( - 0.598, - 0.383, - ), - }), - 'context': , - 'entity_id': 'light.led_keuken_2', + 'entity_id': 'light.cam_garage_indicator_light', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[dj_tmsloaroqavbucgn][light.pokerlamp_1-entry] +# name: test_platform_setup_and_discovery[light.cam_porch_indicator_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, 'supported_color_modes': list([ - , + , ]), }), 'config_entry_id': , @@ -1567,8 +287,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': None, - 'entity_id': 'light.pokerlamp_1', + 'entity_category': , + 'entity_id': 'light.cam_porch_indicator_light', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1580,449 +300,35 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Indicator light', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.ngcubvaqoraolsmtjdswitch_led', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_indicator', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[dj_tmsloaroqavbucgn][light.pokerlamp_1-state] +# name: test_platform_setup_and_discovery[light.cam_porch_indicator_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Pokerlamp 1', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.pokerlamp_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_ufq2xwuzd4nb0qdr][light.sjiethoes-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.sjiethoes', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.rdq0bn4dzuwx2qfujdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_ufq2xwuzd4nb0qdr][light.sjiethoes-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sjiethoes', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.sjiethoes', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_vqwcnabamzrc2kab][light.strip_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.strip_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.bak2crzmabancwqvjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_vqwcnabamzrc2kab][light.strip_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Strip 2', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.strip_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_xokdfs6kh5ednakk][light.erker_1_gold-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.erker_1_gold', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.kkande5hk6sfdkoxjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_xokdfs6kh5ednakk][light.erker_1_gold-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': 255, - 'color_mode': , - 'color_temp': 500, - 'color_temp_kelvin': 2000, - 'friendly_name': 'ERKER 1-Gold ', - 'hs_color': tuple( - 30.601, - 94.547, - ), - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': tuple( - 255, - 137, - 14, - ), - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - 'xy_color': tuple( - 0.598, - 0.383, - ), - }), - 'context': , - 'entity_id': 'light.erker_1_gold', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[dj_zakhnlpdiu0ycdxn][light.stoel-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.stoel', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.nxdcy0uidplnhkazjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_zakhnlpdiu0ycdxn][light.stoel-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Stoel', - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.stoel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dj_zav1pa32pyxray78][light.gengske-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - '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.gengske', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.87yarxyp23ap1vazjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_zav1pa32pyxray78][light.gengske-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, - 'friendly_name': 'Gengske 💡 ', - 'hs_color': None, - 'max_color_temp_kelvin': 6500, - 'max_mireds': 500, - 'min_color_temp_kelvin': 2000, - 'min_mireds': 153, - 'rgb_color': None, + 'friendly_name': 'CAM PORCH Indicator light', 'supported_color_modes': list([ - , - , + , ]), 'supported_features': , - 'xy_color': None, }), 'context': , - 'entity_id': 'light.gengske', + 'entity_id': 'light.cam_porch_indicator_light', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[dj_zputiamzanuk6yky][light.floodlight-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.floodlight', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.yky6kunazmaitupzjdswitch_led', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dj_zputiamzanuk6yky][light.floodlight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'friendly_name': 'Floodlight', - 'hs_color': None, - 'rgb_color': None, - 'supported_color_modes': list([ - , - , - ]), - 'supported_features': , - 'xy_color': None, - }), - 'context': , - 'entity_id': 'light.floodlight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-entry] +# name: test_platform_setup_and_discovery[light.ceiling_fan_with_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2065,7 +371,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][light.ceiling_fan_with_light-state] +# name: test_platform_setup_and_discovery[light.ceiling_fan_with_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': 255, @@ -2103,7 +409,80 @@ 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-entry] +# name: test_platform_setup_and_discovery[light.ceiling_portal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ceiling_portal', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.buzituffc13pgb1jjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ceiling_portal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Ceiling Portal', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.ceiling_portal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2147,7 +526,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[gyd_lgekqfxdabipm3tn][light.colorful_pir_night_light-state] +# name: test_platform_setup_and_discovery[light.colorful_pir_night_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'brightness': None, @@ -2176,14 +555,18 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][light.tower_fan_ca_407g_smart_backlight-entry] +# name: test_platform_setup_and_discovery[light.directietkamer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, 'supported_color_modes': list([ - , + , ]), }), 'config_entry_id': , @@ -2192,8 +575,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': , - 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'entity_category': None, + 'entity_id': 'light.directietkamer', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2205,149 +588,1458 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Backlight', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'backlight', - 'unique_id': 'tuya.lflvu8cazha8af9jsklight', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][light.tower_fan_ca_407g_smart_backlight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'color_mode': , - 'friendly_name': 'Tower Fan CA-407G Smart Backlight', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][light.cam_garage_indicator_light-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': , - 'entity_id': 'light.cam_garage_indicator_light', - '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': 'Indicator light', + 'original_name': None, 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_indicator', + 'unique_id': 'tuya.trffx1ktlyu3tnmljdswitch_led', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][light.cam_garage_indicator_light-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'color_mode': , - 'friendly_name': 'CAM GARAGE Indicator light', - 'supported_color_modes': list([ - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'light.cam_garage_indicator_light', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][light.cam_porch_indicator_light-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': , - 'entity_id': 'light.cam_porch_indicator_light', - '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': 'Indicator light', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_indicator', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][light.cam_porch_indicator_light-state] +# name: test_platform_setup_and_discovery[light.directietkamer-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'brightness': None, 'color_mode': None, - 'friendly_name': 'CAM PORCH Indicator light', + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'DirectietKamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ - , + , ]), 'supported_features': , + 'xy_color': None, }), 'context': , - 'entity_id': 'light.cam_porch_indicator_light', + 'entity_id': 'light.directietkamer', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][light.solar_zijpad-entry] +# name: test_platform_setup_and_discovery[light.dressoir_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.dressoir_spot', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.w8oht6v8aauqa0y8jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.dressoir_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'dressoir spot', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.dressoir_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.erker_1_gold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.erker_1_gold', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.kkande5hk6sfdkoxjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.erker_1_gold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'ERKER 1-Gold ', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.erker_1_gold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.fakkel_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.fakkel_8', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.z8woiryqydmzonjdjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.fakkel_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 70, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'Fakkel 8', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.fakkel_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.floodlight-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.floodlight', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yky6kunazmaitupzjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.floodlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Floodlight', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.floodlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.front_right_lighting_trap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.front_right_lighting_trap', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gjnpc0eojdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.front_right_lighting_trap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Front right Lighting trap', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.front_right_lighting_trap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Garage', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light-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.garage_light', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.r4yrlr705ei31ikmjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 138, + 'color_mode': , + 'friendly_name': 'Garage light', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.garage_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light_1-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.garage_light_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': 'Light 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_light', + 'unique_id': 'tuya.7axah58vfydd8cphjdswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.garage_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Garage Light 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.garage_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.gengske-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.gengske', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.87yarxyp23ap1vazjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.gengske-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Gengske 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.gengske', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.hall', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.6gsqieoh1yzjvxlnjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'hall 💡 ', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.ieskas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.ieskas', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.86kdcut3hiqqddlijdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.ieskas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 285, + 'color_temp_kelvin': 3508, + 'friendly_name': 'Ieskas', + 'hs_color': tuple( + 27.165, + 44.6, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 193, + 141, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.453, + 0.374, + ), + }), + 'context': , + 'entity_id': 'light.ieskas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.led_keuken_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.led_keuken_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.97k3pwirjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.led_keuken_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'LED KEUKEN 2', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.led_keuken_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.led_porch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.led_porch_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7jxnjpiltmj2zyaijdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.led_porch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LED Porch 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.led_porch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.lsc_party_string_light_rgbic_cct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.lsc_party_string_light_rgbic_cct', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.x4nogasbi8ggpb3lcdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lsc_party_string_light_rgbic_cct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LSC Party String Light RGBIC+CCT ', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lsc_party_string_light_rgbic_cct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.master_bedroom_tv_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.master_bedroom_tv_lights', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.vnj3sa6mqahro6phjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.master_bedroom_tv_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Master bedroom TV lights', + 'hs_color': tuple( + 26.072, + 100.0, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 111, + 0, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.632, + 0.358, + ), + }), + 'context': , + 'entity_id': 'light.master_bedroom_tv_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.pokerlamp_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ngcubvaqoraolsmtjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.pokerlamp_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.z7cu5t8bl9tt9fabjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.pokerlamp_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pokerlamp 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.pokerlamp_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.porch_light_e-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.porch_light_e', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.klgxmpwvdhw7tzs8jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.porch_light_e-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Porch light E', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.porch_light_e', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.sjiethoes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.sjiethoes', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.rdq0bn4dzuwx2qfujdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.sjiethoes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sjiethoes', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.sjiethoes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.slaapkamer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.slaapkamer', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ajkdo1bm2rcmpuufjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.slaapkamer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Slaapkamer', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.slaapkamer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.solar_zijpad-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2386,7 +2078,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][light.solar_zijpad-state] +# name: test_platform_setup_and_discovery[light.solar_zijpad-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Solar zijpad', @@ -2403,3 +2095,311 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.stoel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.stoel', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.nxdcy0uidplnhkazjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.stoel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Stoel', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.stoel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.strip_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.strip_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bak2crzmabancwqvjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.strip_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Strip 2', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.strip_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[light.tapparelle_studio_backlight-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': , + 'entity_id': 'light.tapparelle_studio_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.2w46jyhngklcswitch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.tapparelle_studio_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tapparelle studio Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tapparelle_studio_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.tower_fan_ca_407g_smart_backlight-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': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.lflvu8cazha8af9jsklight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.tower_fan_ca_407g_smart_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tower Fan CA-407G Smart Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[light.wc_d1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.wc_d1', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.6o148laaosbf0g4djdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.wc_d1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WC D1', + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.wc_d1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 48256bab849..7ab05e49463 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][number.aqi_alarm_duration-entry] +# name: test_platform_setup_and_discovery[number.aqi_alarm_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][number.aqi_alarm_duration-state] +# name: test_platform_setup_and_discovery[number.aqi_alarm_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -58,650 +58,7 @@ 'state': '1.0', }) # --- -# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][number.cleverio_pf100_feed-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 20.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': None, - 'entity_id': 'number.cleverio_pf100_feed', - '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': 'Feed', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'feed', - 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcmanual_feed', - 'unit_of_measurement': '', - }) -# --- -# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][number.cleverio_pf100_feed-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Cleverio PF100 Feed', - 'max': 20.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '', - }), - 'context': , - 'entity_id': 'number.cleverio_pf100_feed', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.0', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_far_detection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 1000.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.human_presence_office_far_detection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Far detection', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'far_detection', - 'unique_id': 'tuya.kxwleaa2sphfar_detection', - 'unit_of_measurement': 'cm', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_far_detection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Human presence Office Far detection', - 'max': 1000.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 'cm', - }), - 'context': , - 'entity_id': 'number.human_presence_office_far_detection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '220.0', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_near_detection-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 1000.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.human_presence_office_near_detection', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Near detection', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'near_detection', - 'unique_id': 'tuya.kxwleaa2sphnear_detection', - 'unit_of_measurement': 'cm', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_near_detection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Human presence Office Near detection', - 'max': 1000.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 'cm', - }), - 'context': , - 'entity_id': 'number.human_presence_office_near_detection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '40.0', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 10.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.human_presence_office_sensitivity', - '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': 'Sensitivity', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sensitivity', - 'unique_id': 'tuya.kxwleaa2sphsensitivity', - 'unit_of_measurement': 'x', - }) -# --- -# name: test_platform_setup_and_discovery[hps_2aaelwxk][number.human_presence_office_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Human presence Office Sensitivity', - 'max': 10.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 'x', - }), - 'context': , - 'entity_id': 'number.human_presence_office_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_alarm_delay-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.multifunction_alarm_alarm_delay', - '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 delay', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'alarm_delay', - 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_delay_time', - 'unit_of_measurement': 's', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_alarm_delay-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Multifunction alarm Alarm delay', - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 's', - }), - 'context': , - 'entity_id': 'number.multifunction_alarm_alarm_delay', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.0', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_arm_delay-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.multifunction_alarm_arm_delay', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Arm delay', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'arm_delay', - 'unique_id': 'tuya.2pxfek1jjrtctiyglamdelay_set', - 'unit_of_measurement': 's', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_arm_delay-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Multifunction alarm Arm delay', - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 's', - }), - 'context': , - 'entity_id': 'number.multifunction_alarm_arm_delay', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '15.0', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_siren_duration-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.multifunction_alarm_siren_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Siren duration', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'siren_duration', - 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_time', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][number.multifunction_alarm_siren_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Multifunction alarm Siren duration', - 'max': 999.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'number.multifunction_alarm_siren_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.0', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 92.5, - 'min': 25.0, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.sous_vide_cook_temperature', - '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': 'Cook temperature', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_temperature', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_temperature', - 'unit_of_measurement': '℃', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Cook temperature', - 'max': 92.5, - 'min': 25.0, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': '℃', - }), - 'context': , - 'entity_id': 'number.sous_vide_cook_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 5999.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.sous_vide_cook_time', - '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': 'Cook time', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_time', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_time', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Cook time', - 'max': 5999.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.sous_vide_cook_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.v20_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'volume', - 'unique_id': 'tuya.zrrraytdoanz33rldsvolume_set', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Volume', - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.v20_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '95.0', - }) -# --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 30.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.siren_veranda_time', - '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': 'Time', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'time', - 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_time', - 'unit_of_measurement': '', - }) -# --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][number.siren_veranda_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Siren veranda Time', - 'max': 30.0, - 'min': 1.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '', - }), - 'context': , - 'entity_id': 'number.siren_veranda_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10.0', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-entry] +# name: test_platform_setup_and_discovery[number.c9_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -741,7 +98,7 @@ 'unit_of_measurement': '', }) # --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][number.c9_volume-state] +# name: test_platform_setup_and_discovery[number.c9_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'C9 Volume', @@ -759,14 +116,14 @@ 'state': '1.0', }) # --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][number.kabinet_temperature_correction-entry] +# name: test_platform_setup_and_discovery[number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'max': 9.0, - 'min': -9.0, + 'max': 20.0, + 'min': 1.0, 'mode': , 'step': 1.0, }), @@ -776,8 +133,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.kabinet_temperature_correction', + 'entity_category': None, + 'entity_id': 'number.cleverio_pf100_feed', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -789,385 +146,35 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Temperature correction', + 'original_name': 'Feed', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temp_correction', - 'unique_id': 'tuya.dn7cjik6kwtemp_correction', - 'unit_of_measurement': '℃', + 'translation_key': 'feed', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcmanual_feed', + 'unit_of_measurement': '', }) # --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][number.kabinet_temperature_correction-state] +# name: test_platform_setup_and_discovery[number.cleverio_pf100_feed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Кабінет Temperature correction', - 'max': 9.0, - 'min': -9.0, + 'friendly_name': 'Cleverio PF100 Feed', + 'max': 20.0, + 'min': 1.0, 'mode': , 'step': 1.0, - 'unit_of_measurement': '℃', + 'unit_of_measurement': '', }), 'context': , - 'entity_id': 'number.kabinet_temperature_correction', + 'entity_id': 'number.cleverio_pf100_feed', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-2.0', + 'state': '1.0', }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 9.9, - 'min': -9.9, - 'mode': , - 'step': 0.1, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', - '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': 'Temperature correction', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temp_correction', - 'unique_id': 'tuya.j6mn1t4ut5end6ifkwtemp_correction', - 'unit_of_measurement': '℃', - }) -# --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', - 'max': 9.9, - 'min': -9.9, - 'mode': , - 'step': 0.1, - 'unit_of_measurement': '℃', - }), - 'context': , - 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-1.5', - }) -# --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][number.smart_thermostats_temperature_correction-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 9.0, - 'min': -9.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.smart_thermostats_temperature_correction', - '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': 'Temperature correction', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temp_correction', - 'unique_id': 'tuya.sb3zdertrw50bgogkwtemp_correction', - 'unit_of_measurement': '摄氏度', - }) -# --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][number.smart_thermostats_temperature_correction-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'smart thermostats Temperature correction', - 'max': 9.0, - 'min': -9.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '摄氏度', - }), - 'context': , - 'entity_id': 'number.smart_thermostats_temperature_correction', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '-2.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.rainwater_tank_level_alarm_maximum', - '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': 'Alarm maximum', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'alarm_maximum', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymax_set', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Rainwater Tank Level Alarm maximum', - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.rainwater_tank_level_alarm_maximum', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '90.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.rainwater_tank_level_alarm_minimum', - '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': 'Alarm minimum', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'alarm_minimum', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymini_set', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Rainwater Tank Level Alarm minimum', - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1.0, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.rainwater_tank_level_alarm_minimum', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 3.0, - 'min': 0.1, - 'mode': , - 'step': 0.001, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.rainwater_tank_level_installation_height', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Installation height', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'installation_height', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyinstallation_height', - 'unit_of_measurement': 'm', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Rainwater Tank Level Installation height', - 'max': 3.0, - 'min': 0.1, - 'mode': , - 'step': 0.001, - 'unit_of_measurement': 'm', - }), - 'context': , - 'entity_id': 'number.rainwater_tank_level_installation_height', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.35', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 2.7, - 'min': 0.1, - 'mode': , - 'step': 0.001, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Maximum liquid depth', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'maximum_liquid_depth', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth_max', - 'unit_of_measurement': 'm', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', - 'max': 2.7, - 'min': 0.1, - 'mode': , - 'step': 0.001, - 'unit_of_measurement': 'm', - }), - 'context': , - 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.1', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-entry] +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_maximum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1207,7 +214,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-state] +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_maximum-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'House Water Level Alarm maximum', @@ -1225,7 +232,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-entry] +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_minimum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1265,7 +272,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-state] +# name: test_platform_setup_and_discovery[number.house_water_level_alarm_minimum-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'House Water Level Alarm minimum', @@ -1283,7 +290,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-entry] +# name: test_platform_setup_and_discovery[number.house_water_level_installation_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1323,7 +330,7 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-state] +# name: test_platform_setup_and_discovery[number.house_water_level_installation_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -1342,7 +349,7 @@ 'state': '0.56', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-entry] +# name: test_platform_setup_and_discovery[number.house_water_level_maximum_liquid_depth-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1382,7 +389,7 @@ 'unit_of_measurement': 'm', }) # --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-state] +# name: test_platform_setup_and_discovery[number.house_water_level_maximum_liquid_depth-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -1401,3 +408,996 @@ 'state': '0.1', }) # --- +# name: test_platform_setup_and_discovery[number.human_presence_office_far_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_far_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Far detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'far_detection', + 'unique_id': 'tuya.kxwleaa2sphfar_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_far_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Far detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_far_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_near_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_near_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Near detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'near_detection', + 'unique_id': 'tuya.kxwleaa2sphnear_detection', + 'unit_of_measurement': 'cm', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_near_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Human presence Office Near detection', + 'max': 1000.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cm', + }), + 'context': , + 'entity_id': 'number.human_presence_office_near_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.human_presence_office_sensitivity', + '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': 'Sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensitivity', + 'unique_id': 'tuya.kxwleaa2sphsensitivity', + 'unit_of_measurement': 'x', + }) +# --- +# name: test_platform_setup_and_discovery[number.human_presence_office_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Human presence Office Sensitivity', + 'max': 10.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'x', + }), + 'context': , + 'entity_id': 'number.human_presence_office_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.kabinet_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kabinet_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.dn7cjik6kwtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.kabinet_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.kabinet_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + '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 delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_delay_time', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Alarm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_arm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_delay', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamdelay_set', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_arm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Arm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_siren_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Siren duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_duration', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamalarm_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[number.multifunction_alarm_siren_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Siren duration', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwymini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Installation height', + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.35', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[number.rainwater_tank_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[number.siren_veranda_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.siren_veranda_time', + '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': 'Time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time', + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_time', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[number.siren_veranda_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda Time', + 'max': 30.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'number.siren_veranda_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_thermostats_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.sb3zdertrw50bgogkwtemp_correction', + 'unit_of_measurement': '摄氏度', + }) +# --- +# name: test_platform_setup_and_discovery[number.smart_thermostats_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Temperature correction', + 'max': 9.0, + 'min': -9.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '摄氏度', + }), + 'context': , + 'entity_id': 'number.smart_thermostats_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-2.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_temperature', + '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': 'Cook temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook temperature', + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_time', + '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': 'Cook time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmcook_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[number.sous_vide_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook time', + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[number.v20_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.zrrraytdoanz33rldsvolume_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[number.v20_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Volume', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.v20_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95.0', + }) +# --- +# name: test_platform_setup_and_discovery[number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwtemp_correction', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.5', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 571f8358870..df0a5b38a99 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,13 +1,14 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-entry] +# name: test_platform_setup_and_discovery[select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ - 'morning', - 'night', + '0', + '1', + '2', ]), }), 'config_entry_id': , @@ -17,7 +18,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.blinds_mode', + 'entity_id': 'select.4_433_power_on_behavior', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -29,91 +30,35 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Mode', + 'original_name': 'Power on behavior', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'curtain_mode', - 'unique_id': 'tuya.nr26obpclcmode', + 'translation_key': 'relay_status', + 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtrelay_status', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cl_cpbo62rn][select.blinds_mode-state] +# name: test_platform_setup_and_discovery[select.4_433_power_on_behavior-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'blinds Mode', + 'friendly_name': '4-433 Power on behavior', 'options': list([ - 'morning', - 'night', + '0', + '1', + '2', ]), }), 'context': , - 'entity_id': 'select.blinds_mode', + 'entity_id': 'select.4_433_power_on_behavior', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'morning', + 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'forward', - 'back', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.kitchen_blinds_motor_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': 'Motor mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'curtain_motor_mode', - 'unique_id': 'tuya.dke76hazlccontrol_back_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cl_zah67ekd][select.kitchen_blinds_motor_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Kitchen Blinds Motor mode', - 'options': list([ - 'forward', - 'back', - ]), - }), - 'context': , - 'entity_id': 'select.kitchen_blinds_motor_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'forward', - }) -# --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][select.aqi_volume-entry] +# name: test_platform_setup_and_discovery[select.aqi_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -155,7 +100,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][select.aqi_volume-state] +# name: test_platform_setup_and_discovery[select.aqi_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI Volume', @@ -174,17 +119,15 @@ 'state': 'low', }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][select.dehumidifer_countdown-entry] +# name: test_platform_setup_and_discovery[select.blinds_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ - 'cancel', - '1h', - '2h', - '3h', + 'morning', + 'night', ]), }), 'config_entry_id': , @@ -194,7 +137,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.dehumidifer_countdown', + 'entity_id': 'select.blinds_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -206,335 +149,34 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Countdown', + 'original_name': 'Mode', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'countdown', - 'unique_id': 'tuya.ifzgvpgoodrfw2aksccountdown_set', + 'translation_key': 'curtain_mode', + 'unique_id': 'tuya.nr26obpclcmode', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][select.dehumidifer_countdown-state] +# name: test_platform_setup_and_discovery[select.blinds_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifer Countdown', + 'friendly_name': 'blinds Mode', 'options': list([ - 'cancel', - '1h', - '2h', - '3h', + 'morning', + 'night', ]), }), 'context': , - 'entity_id': 'select.dehumidifer_countdown', + 'entity_id': 'select.blinds_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'morning', }) # --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][select.dehumidifier_countdown-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'cancel', - '1h', - '2h', - '3h', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.dehumidifier_countdown', - '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': 'Countdown', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'countdown', - 'unique_id': 'tuya.2myxayqtud9aqbizsccountdown_set', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][select.dehumidifier_countdown-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier Countdown', - 'options': list([ - 'cancel', - '1h', - '2h', - '3h', - ]), - }), - 'context': , - 'entity_id': 'select.dehumidifier_countdown', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'cancel', - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'smart', - 'interim', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_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': 'Odor elimination mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'odor_elimination_mode', - 'unique_id': 'tuya.rl39uwgaqwjwcwork_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][select.smart_odor_eliminator_pro_odor_elimination_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', - 'options': list([ - 'smart', - 'interim', - ]), - }), - 'context': , - 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_indicator_light_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'relay', - 'pos', - 'none', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.wallwasher_front_indicator_light_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': 'Indicator light mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'light_mode', - 'unique_id': 'tuya.pdasfna8fswh4a0tzclight_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_indicator_light_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'wallwasher front Indicator light mode', - 'options': list([ - 'relay', - 'pos', - 'none', - ]), - }), - 'context': , - 'entity_id': 'select.wallwasher_front_indicator_light_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_power_on_behavior-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'power_off', - 'power_on', - 'last', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.wallwasher_front_power_on_behavior', - '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': 'Power on behavior', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'relay_status', - 'unique_id': 'tuya.pdasfna8fswh4a0tzcrelay_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][select.wallwasher_front_power_on_behavior-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'wallwasher front Power on behavior', - 'options': list([ - 'power_off', - 'power_on', - 'last', - ]), - }), - 'context': , - 'entity_id': 'select.wallwasher_front_power_on_behavior', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'cancel', - '1h', - '2h', - '4h', - '8h', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.ceiling_fan_with_light_countdown', - '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': 'Countdown', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'countdown', - 'unique_id': 'tuya.ijzjlqwmv1blwe0gsfcountdown_set', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[fs_g0ewlb1vmwqljzji][select.ceiling_fan_with_light_countdown-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ceiling Fan With Light Countdown', - 'options': list([ - 'cancel', - '1h', - '2h', - '4h', - '8h', - ]), - }), - 'context': , - 'entity_id': 'select.ceiling_fan_with_light_countdown', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-entry] +# name: test_platform_setup_and_discovery[select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -578,7 +220,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][select.bree_countdown-state] +# name: test_platform_setup_and_discovery[select.bree_countdown-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Bree Countdown', @@ -599,17 +241,15 @@ 'state': 'cancel', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-entry] +# name: test_platform_setup_and_discovery[select.c9_ipc_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ - 'smart', - 'zone', - 'pose', - 'part', + '0', + '1', ]), }), 'config_entry_id': , @@ -619,7 +259,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.v20_mode', + 'entity_id': 'select.c9_ipc_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -631,45 +271,43 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Mode', + 'original_name': 'IPC mode', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'vacuum_mode', - 'unique_id': 'tuya.zrrraytdoanz33rldsmode', + 'translation_key': 'ipc_work_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsipc_work_mode', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_mode-state] +# name: test_platform_setup_and_discovery[select.c9_ipc_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Mode', + 'friendly_name': 'C9 IPC mode', 'options': list([ - 'smart', - 'zone', - 'pose', - 'part', + '0', + '1', ]), }), 'context': , - 'entity_id': 'select.v20_mode', + 'entity_id': 'select.c9_ipc_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-entry] +# name: test_platform_setup_and_discovery[select.c9_motion_detection_sensitivity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ - 'low', - 'middle', - 'high', + '0', + '1', + '2', ]), }), 'config_entry_id': , @@ -679,7 +317,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.v20_water_tank_adjustment', + 'entity_id': 'select.c9_motion_detection_sensitivity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -691,35 +329,857 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Water tank adjustment', + 'original_name': 'Motion detection sensitivity', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'vacuum_cistern', - 'unique_id': 'tuya.zrrraytdoanz33rldscistern', + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_sensitivity', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][select.v20_water_tank_adjustment-state] +# name: test_platform_setup_and_discovery[select.c9_motion_detection_sensitivity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Water tank adjustment', + 'friendly_name': 'C9 Motion detection sensitivity', 'options': list([ - 'low', - 'middle', - 'high', + '0', + '1', + '2', ]), }), 'context': , - 'entity_id': 'select.v20_water_tank_adjustment', + 'entity_id': 'select.c9_motion_detection_sensitivity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'middle', + 'state': 'unknown', }) # --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-entry] +# name: test_platform_setup_and_discovery[select.c9_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.c9_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.c9_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.c9_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_night_vision', + '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': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + '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': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_garage_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_garage_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + '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': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.cam_porch_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.cam_porch_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.ceiling_fan_with_light_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.ijzjlqwmv1blwe0gsfcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ceiling_fan_with_light_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ceiling Fan With Light Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '4h', + '8h', + ]), + }), + 'context': , + 'entity_id': 'select.ceiling_fan_with_light_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifer_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifer_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.ifzgvpgoodrfw2aksccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifer_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifer_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifier_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.2myxayqtud9aqbizsccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisiers_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisiers_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisiers_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisiers Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.framboisiers_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.jardin_fraises_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.jardin_fraises_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'jardin Fraises Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.jardin_fraises_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.kitchen_blinds_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_blinds_motor_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': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.dke76hazlccontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.kitchen_blinds_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Blinds Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- +# name: test_platform_setup_and_discovery[select.siren_veranda_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -761,7 +1221,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][select.siren_veranda_volume-state] +# name: test_platform_setup_and_discovery[select.siren_veranda_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Siren veranda Volume', @@ -780,16 +1240,15 @@ 'state': 'middle', }) # --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_motion_detection_sensitivity-entry] +# name: test_platform_setup_and_discovery[select.smart_odor_eliminator_pro_odor_elimination_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '1', - '2', + 'smart', + 'interim', ]), }), 'config_entry_id': , @@ -799,7 +1258,7 @@ 'disabled_by': None, 'domain': 'select', 'entity_category': , - 'entity_id': 'select.cam_garage_motion_detection_sensitivity', + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -811,731 +1270,34 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Motion detection sensitivity', + 'original_name': 'Odor elimination mode', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_sensitivity', + 'translation_key': 'odor_elimination_mode', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_mode', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_motion_detection_sensitivity-state] +# name: test_platform_setup_and_discovery[select.smart_odor_eliminator_pro_odor_elimination_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Motion detection sensitivity', + 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', 'options': list([ - '0', - '1', - '2', + 'smart', + 'interim', ]), }), 'context': , - 'entity_id': 'select.cam_garage_motion_detection_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_night_vision-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_garage_night_vision', - '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': 'Night vision', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'basic_nightvision', - 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_nightvision', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_night_vision-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Night vision', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.cam_garage_night_vision', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_record_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_garage_record_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': 'Record mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'record_mode', - 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_record_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Record mode', - 'options': list([ - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.cam_garage_record_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_sound_detection_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_garage_sound_detection_sensitivity', - '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': 'Sound detection sensitivity', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'decibel_sensitivity', - 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_sensitivity', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][select.cam_garage_sound_detection_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Sound detection sensitivity', - 'options': list([ - '0', - '1', - ]), - }), - 'context': , - 'entity_id': 'select.cam_garage_sound_detection_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_motion_detection_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_porch_motion_detection_sensitivity', - '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': 'Motion detection sensitivity', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_sensitivity', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_motion_detection_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Motion detection sensitivity', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.cam_porch_motion_detection_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_record_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_porch_record_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': 'Record mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'record_mode', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_record_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Record mode', - 'options': list([ - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.cam_porch_record_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_sound_detection_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.cam_porch_sound_detection_sensitivity', - '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': 'Sound detection sensitivity', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'decibel_sensitivity', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_sensitivity', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][select.cam_porch_sound_detection_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Sound detection sensitivity', - 'options': list([ - '0', - '1', - ]), - }), - 'context': , - 'entity_id': 'select.cam_porch_sound_detection_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.c9_ipc_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': 'IPC mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'ipc_work_mode', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsipc_work_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_ipc_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 IPC mode', - 'options': list([ - '0', - '1', - ]), - }), - 'context': , - 'entity_id': 'select.c9_ipc_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.c9_motion_detection_sensitivity', - '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': 'Motion detection sensitivity', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_sensitivity', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_sensitivity', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_motion_detection_sensitivity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Motion detection sensitivity', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.c9_motion_detection_sensitivity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.c9_record_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': 'Record mode', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'record_mode', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][select.c9_record_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Record mode', - 'options': list([ - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.c9_record_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.jardin_fraises_power_on_behavior', - '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': 'Power on behavior', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'relay_status', - 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtrelay_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][select.jardin_fraises_power_on_behavior-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'jardin Fraises Power on behavior', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.jardin_fraises_power_on_behavior', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.framboisiers_power_on_behavior', - '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': 'Power on behavior', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'relay_status', - 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtrelay_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][select.framboisiers_power_on_behavior-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Framboisiers Power on behavior', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.framboisiers_power_on_behavior', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': , - 'entity_id': 'select.4_433_power_on_behavior', - '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': 'Power on behavior', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'relay_status', - 'unique_id': 'tuya.xenxir4a0tn0p1qcqdtrelay_status', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][select.4_433_power_on_behavior-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '4-433 Power on behavior', - 'options': list([ - '0', - '1', - '2', - ]), - }), - 'context': , - 'entity_id': 'select.4_433_power_on_behavior', + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-entry] +# name: test_platform_setup_and_discovery[select.socket3_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1576,7 +1338,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][select.socket3_power_on_behavior-state] +# name: test_platform_setup_and_discovery[select.socket3_power_on_behavior-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Socket3 Power on behavior', @@ -1594,3 +1356,241 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.v20_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.zrrraytdoanz33rldsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Mode', + 'options': list([ + 'smart', + 'zone', + 'pose', + 'part', + ]), + }), + 'context': , + 'entity_id': 'select.v20_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_water_tank_adjustment-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.v20_water_tank_adjustment', + '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': 'Water tank adjustment', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_cistern', + 'unique_id': 'tuya.zrrraytdoanz33rldscistern', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.v20_water_tank_adjustment-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Water tank adjustment', + 'options': list([ + 'low', + 'middle', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.v20_water_tank_adjustment', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pdasfna8fswh4a0tzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.wallwasher_front_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.wallwasher_front_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 2fb7f2a0ed0..f7c304c91e3 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,54 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-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.lounge_dark_blind_last_operation_duration', - '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': 'Last operation duration', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_operation_duration', - 'unique_id': 'tuya.g1efxsqnp33cg8r3lctime_total', - 'unit_of_measurement': 'ms', - }) -# --- -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][sensor.lounge_dark_blind_last_operation_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lounge Dark Blind Last operation duration', - 'unit_of_measurement': 'ms', - }), - 'context': , - 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25400.0', - }) -# --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-entry] +# name: test_platform_setup_and_discovery[sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -85,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_battery-state] +# name: test_platform_setup_and_discovery[sensor.aqi_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -101,7 +52,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_formaldehyde-entry] +# name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -138,7 +89,7 @@ 'unit_of_measurement': 'mg/m3', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_formaldehyde-state] +# name: test_platform_setup_and_discovery[sensor.aqi_formaldehyde-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI Formaldehyde', @@ -153,7 +104,7 @@ 'state': '0.002', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_humidity-entry] +# name: test_platform_setup_and_discovery[sensor.aqi_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -190,7 +141,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_humidity-state] +# name: test_platform_setup_and_discovery[sensor.aqi_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -206,7 +157,7 @@ 'state': '53.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_temperature-entry] +# name: test_platform_setup_and_discovery[sensor.aqi_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -246,7 +197,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_temperature-state] +# name: test_platform_setup_and_discovery[sensor.aqi_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -262,7 +213,7 @@ 'state': '26.0', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_volatile_organic_compounds-entry] +# name: test_platform_setup_and_discovery[sensor.aqi_volatile_organic_compounds-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -299,7 +250,7 @@ 'unit_of_measurement': 'mg/m³', }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][sensor.aqi_volatile_organic_compounds-state] +# name: test_platform_setup_and_discovery[sensor.aqi_volatile_organic_compounds-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds', @@ -315,113 +266,7 @@ 'state': '0.018', }) # --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][sensor.dehumidifer_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.dehumidifer_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.ifzgvpgoodrfw2akschumidity_indoor', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][sensor.dehumidifer_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Dehumidifer Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.dehumidifer_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][sensor.dehumidifier_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.dehumidifier_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.2myxayqtud9aqbizschumidity_indoor', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][sensor.dehumidifier_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Dehumidifier Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.dehumidifier_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '47.0', - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_battery-entry] +# name: test_platform_setup_and_discovery[sensor.bathroom_smart_switch_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -436,7 +281,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'entity_id': 'sensor.bathroom_smart_switch_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -454,1886 +299,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.rl39uwgaqwjwcbattery_percentage', + 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwbattery_percentage', 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_battery-state] +# name: test_platform_setup_and_discovery[sensor.bathroom_smart_switch_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'friendly_name': 'Bathroom Smart Switch Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_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': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.smart_odor_eliminator_pro_status', - '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': 'Status', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'odor_elimination_status', - 'unique_id': 'tuya.rl39uwgaqwjwcwork_state_e', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][sensor.smart_odor_eliminator_pro_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart Odor Eliminator-Pro Status', - }), - 'context': , - 'entity_id': 'sensor.smart_odor_eliminator_pro_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][sensor.cleverio_pf100_last_amount-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.cleverio_pf100_last_amount', - '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': 'Last amount', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'last_amount', - 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcfeed_report', - 'unit_of_measurement': '', - }) -# --- -# name: test_platform_setup_and_discovery[cwwsq_wfkzyy0evslzsmoi][sensor.cleverio_pf100_last_amount-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Cleverio PF100 Last amount', - 'state_class': , - 'unit_of_measurement': '', - }), - 'context': , - 'entity_id': 'sensor.cleverio_pf100_last_amount', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.0', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_filter_duration-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.pixi_smart_drinking_fountain_filter_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Filter duration', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'filter_duration', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_life', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_filter_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.pixi_smart_drinking_fountain_filter_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '18965.0', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_uv_runtime-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.pixi_smart_drinking_fountain_uv_runtime', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'UV runtime', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'uv_runtime', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv_runtime', - 'unit_of_measurement': 's', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_uv_runtime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', - 'state_class': , - 'unit_of_measurement': 's', - }), - 'context': , - 'entity_id': 'sensor.pixi_smart_drinking_fountain_uv_runtime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_level-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.pixi_smart_drinking_fountain_water_level', - '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': 'Water level', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'water_level_state', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_level', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain Water level', - }), - 'context': , - 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'level_3', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_pump_duration-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.pixi_smart_drinking_fountain_water_pump_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Water pump duration', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'pump_time', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_time', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_pump_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '18965.0', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_usage_duration-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.pixi_smart_drinking_fountain_water_usage_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Water usage duration', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'water_time', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_time', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_usage_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_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.hvac_meter_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': 'Current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'current', - 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_current', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'HVAC Meter Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.hvac_meter_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.083', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_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.hvac_meter_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_power', - 'unit_of_measurement': 'W', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'HVAC Meter Power', - 'state_class': , - 'unit_of_measurement': 'W', - }), - 'context': , - 'entity_id': 'sensor.hvac_meter_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '6.4', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_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.hvac_meter_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'voltage', - 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][sensor.hvac_meter_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'HVAC Meter Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.hvac_meter_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '121.7', - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_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': 'Current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'current', - 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_current', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'droger Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.droger_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.754', - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_power', - 'unit_of_measurement': 'W', - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'droger Power', - 'state_class': , - 'unit_of_measurement': 'W', - }), - 'context': , - 'entity_id': 'sensor.droger_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '593.5', - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_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.droger_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'voltage', - 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][sensor.droger_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'droger Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.droger_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '222.4', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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': 'Current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'current', - 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_current', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': '一路带计量磁保持通断器 Current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.198', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_power', - 'unit_of_measurement': 'W', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': '一路带计量磁保持通断器 Power', - 'state_class': , - 'unit_of_measurement': 'W', - }), - 'context': , - 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '495.3', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'voltage', - 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': '一路带计量磁保持通断器 Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '231.4', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase A current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_a_current', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_aelectriccurrent', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.637', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase A power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_a_power', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_apower', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.108', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase A voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_a_voltage', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_avoltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_a_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '221.1', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase B current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_b_current', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_belectriccurrent', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '11.203', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase B power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_b_power', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bpower', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.41', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase B voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_b_voltage', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bvoltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_b_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '218.7', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_current', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase C current', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_c_current', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_celectriccurrent', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_current-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.913', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase C power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_c_power', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cpower', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.092', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase C voltage', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_c_voltage', - 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cvoltage', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[dlq_kxdr6su0c55p7bbo][sensor.metering_3pn_wifi_stable_phase_c_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '220.4', - }) -# --- -# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_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.luminosite_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.ohefbbk9gcdlbattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Luminosité Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.luminosite_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '91.0', - }) -# --- -# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_illuminance-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.luminosite_illuminance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Illuminance', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'illuminance', - 'unique_id': 'tuya.ohefbbk9gcdlbright_value', - 'unit_of_measurement': 'lx', - }) -# --- -# name: test_platform_setup_and_discovery[ldcg_9kbbfeho][sensor.luminosite_illuminance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'illuminance', - 'friendly_name': 'Luminosité Illuminance', - 'state_class': , - 'unit_of_measurement': 'lx', - }), - 'context': , - 'entity_id': 'sensor.luminosite_illuminance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16.0', - }) -# --- -# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][sensor.door_garage_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.door_garage_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmbattery', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[mcs_7jIGJAymiH8OsFFb][sensor.door_garage_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Door Garage Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.door_garage_battery', + 'entity_id': 'sensor.bathroom_smart_switch_battery', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_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.sous_vide_current_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Current temperature', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'current_temperature', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmtemp_current', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Sous Vide Current temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.sous_vide_current_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_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': None, - 'entity_id': 'sensor.sous_vide_remaining_time', - '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': 'Remaining time', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'remaining_time', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmremain_time', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Remaining time', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.sous_vide_remaining_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_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': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.sous_vide_status', - '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': 'Status', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sous_vide_status', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmstatus', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_status-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Status', - }), - 'context': , - 'entity_id': 'sensor.sous_vide_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.rat_trap_hedge_battery_state', - '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': 'Battery state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_state', - 'unique_id': 'tuya.hkm4px9ohzozxma3ripbattery_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'rat trap hedge Battery state', - }), - 'context': , - 'entity_id': 'sensor.rat_trap_hedge_battery_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'low', - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][sensor.motion_sensor_lidl_zigbee_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.motion_sensor_lidl_zigbee_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.s3zzjdcfripbattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[pir_fcdjzz3s][sensor.motion_sensor_lidl_zigbee_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Motion sensor lidl zigbee Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][sensor.pir_outside_stairs_battery_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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.pir_outside_stairs_battery_state', - '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': 'Battery state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_state', - 'unique_id': 'tuya.zoytcemodrn39zqwripbattery_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pir_wqz93nrdomectyoz][sensor.pir_outside_stairs_battery_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIR outside stairs Battery state', - }), - 'context': , - 'entity_id': 'sensor.pir_outside_stairs_battery_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'middle', - }) -# --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2373,7 +359,7 @@ 'unit_of_measurement': 'hPa', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pressure', @@ -2389,7 +375,7 @@ 'state': '1004.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2424,7 +410,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', @@ -2437,7 +423,7 @@ 'state': 'high', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2474,7 +460,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2490,7 +476,7 @@ 'state': '52.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2527,7 +513,7 @@ 'unit_of_measurement': 'lx', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'illuminance', @@ -2543,7 +529,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2580,7 +566,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2596,7 +582,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2633,7 +619,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2649,7 +635,7 @@ 'state': '99.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2686,7 +672,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2702,7 +688,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2739,7 +725,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_outdoor_humidity_channel_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -2755,7 +741,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2795,7 +781,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2811,7 +797,7 @@ 'state': '-40.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2851,7 +837,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2867,7 +853,7 @@ 'state': '19.3', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2907,7 +893,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2923,7 +909,7 @@ 'state': '25.2', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2963,7 +949,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature_channel_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -2979,7 +965,7 @@ 'state': '-40.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3019,7 +1005,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -3035,7 +1021,7 @@ 'state': '24.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-entry] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3078,7 +1064,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_fsea1lat3vuktbt6][sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] +# name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_wind_speed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'wind_speed', @@ -3094,7 +1080,445 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-entry] +# name: test_platform_setup_and_discovery[sensor.c9_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.c9_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspswireless_electricity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.c9_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'C9 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.c9_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-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.cleverio_pf100_last_amount', + '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': 'Last amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_amount', + 'unique_id': 'tuya.iomszlsve0yyzkfwqswwcfeed_report', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.cleverio_pf100_last_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cleverio PF100 Last amount', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.cleverio_pf100_last_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifer_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.dehumidifer_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ifzgvpgoodrfw2akschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifer Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifer_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifier_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.dehumidifier_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.2myxayqtud9aqbizschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.door_garage_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.door_garage_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bFFsO8HimyAJGIj7scmbattery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.door_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Door Garage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.door_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_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.droger_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'droger Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.754', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_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.droger_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'droger Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.droger_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '593.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_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.droger_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.droger_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'droger Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.droger_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '222.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.frysen_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3129,7 +1553,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_battery_state-state] +# name: test_platform_setup_and_discovery[sensor.frysen_battery_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Frysen Battery state', @@ -3142,7 +1566,7 @@ 'state': 'high', }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_humidity-entry] +# name: test_platform_setup_and_discovery[sensor.frysen_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3179,7 +1603,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_humidity-state] +# name: test_platform_setup_and_discovery[sensor.frysen_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -3195,7 +1619,7 @@ 'state': '38.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_probe_temperature-entry] +# name: test_platform_setup_and_discovery[sensor.frysen_probe_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3235,7 +1659,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_probe_temperature-state] +# name: test_platform_setup_and_discovery[sensor.frysen_probe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -3251,7 +1675,7 @@ 'state': '-13.0', }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_temperature-entry] +# name: test_platform_setup_and_discovery[sensor.frysen_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3291,7 +1715,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[qxj_is2indt9nlth6esa][sensor.frysen_temperature-state] +# name: test_platform_setup_and_discovery[sensor.frysen_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -3307,7 +1731,7 @@ 'state': '22.2', }) # --- -# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][sensor.gas_sensor_gas-entry] +# name: test_platform_setup_and_discovery[sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3344,7 +1768,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_platform_setup_and_discovery[rqbj_4iqe2hsfyd86kwwc][sensor.gas_sensor_gas-state] +# name: test_platform_setup_and_discovery[sensor.gas_sensor_gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Gas sensor Gas', @@ -3359,7 +1783,7 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_battery-entry] +# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3373,8 +1797,64 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.v20_battery', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_level-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.house_water_level_liquid_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3384,42 +1864,39 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'Battery', + 'original_name': 'Liquid level', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.zrrraytdoanz33rldselectricity_left', + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_level_percent', 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_battery-state] +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'V20 Battery', + 'friendly_name': 'House Water Level Liquid level', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.v20_battery', + 'entity_id': 'sensor.house_water_level_liquid_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-entry] +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3427,7 +1904,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.v20_cleaning_area', + 'entity_id': 'sensor.house_water_level_liquid_state', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3439,447 +1916,253 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cleaning area', + 'original_name': 'Liquid state', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'cleaning_area', - 'unique_id': 'tuya.zrrraytdoanz33rldsclean_area', - 'unit_of_measurement': '㎡', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_area-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Cleaning area', - 'state_class': , - 'unit_of_measurement': '㎡', - }), - 'context': , - 'entity_id': 'sensor.v20_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-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.v20_cleaning_time', - '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': 'Cleaning time', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cleaning_time', - 'unique_id': 'tuya.zrrraytdoanz33rldsclean_time', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_cleaning_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Cleaning time', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-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.v20_duster_cloth_lifetime', - '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': 'Duster cloth lifetime', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'duster_cloth_life', - 'unique_id': 'tuya.zrrraytdoanz33rldsduster_cloth', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_duster_cloth_lifetime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Duster cloth lifetime', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_duster_cloth_lifetime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9000.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-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.v20_filter_lifetime', - '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': 'Filter lifetime', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': 'tuya.zrrraytdoanz33rldsfilter', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_filter_lifetime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Filter lifetime', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_filter_lifetime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8956.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-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.v20_rolling_brush_lifetime', - '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': 'Rolling brush lifetime', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rolling_brush_life', - 'unique_id': 'tuya.zrrraytdoanz33rldsroll_brush', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_rolling_brush_lifetime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Rolling brush lifetime', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_rolling_brush_lifetime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17948.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-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.v20_side_brush_lifetime', - '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': 'Side brush lifetime', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'side_brush_life', - 'unique_id': 'tuya.zrrraytdoanz33rldsedge_brush', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_side_brush_lifetime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Side brush lifetime', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_side_brush_lifetime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8944.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-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.v20_total_cleaning_area', - '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': 'Total cleaning area', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'total_cleaning_area', - 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_area', - 'unit_of_measurement': '㎡', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_area-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Total cleaning area', - 'state_class': , - 'unit_of_measurement': '㎡', - }), - 'context': , - 'entity_id': 'sensor.v20_total_cleaning_area', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '24.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-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.v20_total_cleaning_time', - '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': 'Total cleaning time', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'total_cleaning_time', - 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_time', - 'unit_of_measurement': 'min', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Total cleaning time', - 'state_class': , - 'unit_of_measurement': 'min', - }), - 'context': , - 'entity_id': 'sensor.v20_total_cleaning_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42.0', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-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.v20_total_cleaning_times', - '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': 'Total cleaning times', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'total_cleaning_times', - 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_count', + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_state', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][sensor.v20_total_cleaning_times-state] +# name: test_platform_setup_and_discovery[sensor.house_water_level_liquid_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Total cleaning times', - 'state_class': , + 'friendly_name': 'House Water Level Liquid state', }), 'context': , - 'entity_id': 'sensor.v20_total_cleaning_times', + 'entity_id': 'sensor.house_water_level_liquid_state', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.0', + 'state': 'upper_alarm', }) # --- -# name: test_platform_setup_and_discovery[sj_tgvtvdoc][sensor.tournesol_battery-entry] +# name: test_platform_setup_and_discovery[sensor.hvac_meter_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.hvac_meter_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'HVAC Meter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.083', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_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.hvac_meter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'HVAC Meter Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_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.hvac_meter_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.tcdk0skzcpisexj2zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hvac_meter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'HVAC Meter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-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.lounge_dark_blind_last_operation_duration', + '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': 'Last operation duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_operation_duration', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lctime_total', + 'unit_of_measurement': 'ms', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Last operation duration', + 'unit_of_measurement': 'ms', + }), + 'context': , + 'entity_id': 'sensor.lounge_dark_blind_last_operation_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25400.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3894,7 +2177,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.tournesol_battery', + 'entity_id': 'sensor.luminosite_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3912,27 +2195,752 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.codvtvgtjsbattery_percentage', + 'unique_id': 'tuya.ohefbbk9gcdlbattery_percentage', 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[sj_tgvtvdoc][sensor.tournesol_battery-state] +# name: test_platform_setup_and_discovery[sensor.luminosite_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Tournesol Battery', + 'friendly_name': 'Luminosité Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.tournesol_battery', + 'entity_id': 'sensor.luminosite_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '98.0', + 'state': '91.0', }) # --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-entry] +# name: test_platform_setup_and_discovery[sensor.luminosite_illuminance-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.luminosite_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.ohefbbk9gcdlbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.luminosite_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Luminosité Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.luminosite_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_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.meter_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Meter Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.62', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_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.meter_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Meter Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.185', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_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.meter_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.meter_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Meter Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.637', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.108', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '221.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.203', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.913', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.092', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3947,7 +2955,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.c9_battery', + 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3965,27 +2973,1125 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'battery', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspswireless_electricity', + 'unique_id': 'tuya.s3zzjdcfripbattery_percentage', 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][sensor.c9_battery-state] +# name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'C9 Battery', + 'friendly_name': 'Motion sensor lidl zigbee Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.c9_battery', + 'entity_id': 'sensor.motion_sensor_lidl_zigbee_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80.0', + 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_current-entry] +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'NP DownStairs North Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'NP DownStairs North Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_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.np_downstairs_north_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.np_downstairs_north_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'NP DownStairs North Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.uew54dymycjwzbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patates Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.patates_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.uew54dymycjwzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patates Battery state', + }), + 'context': , + 'entity_id': 'sensor.patates_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_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.patates_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.uew54dymycjwzhumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Patates Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.patates_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_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.patates_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.uew54dymycjwztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.patates_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Patates Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.patates_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pir_outside_stairs_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.zoytcemodrn39zqwripbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pir_outside_stairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIR outside stairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.pir_outside_stairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_filter_duration-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.pixi_smart_drinking_fountain_filter_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_duration', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_life', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_filter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_filter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_uv_runtime-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.pixi_smart_drinking_fountain_uv_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'UV runtime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_runtime', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv_runtime', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_uv_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_uv_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_level-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.pixi_smart_drinking_fountain_water_level', + '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': 'Water level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level_state', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water level', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_3', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_pump_duration-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.pixi_smart_drinking_fountain_water_pump_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water pump duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_pump_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_pump_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_usage_duration-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.pixi_smart_drinking_fountain_water_usage_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water usage duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_time', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pixi_smart_drinking_fountain_water_usage_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_usage_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-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.rainwater_tank_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.455', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_level-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.rainwater_tank_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rat_trap_hedge_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.hkm4px9ohzozxma3ripbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rat_trap_hedge_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'rat trap hedge Battery state', + }), + 'context': , + 'entity_id': 'sensor.rat_trap_hedge_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_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.smart_odor_eliminator_pro_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.rl39uwgaqwjwcbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_status', + 'unique_id': 'tuya.rl39uwgaqwjwcwork_state_e', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Status', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_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.smoke_detector_upstairs_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': ' Smoke detector upstairs Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': ' Smoke detector upstairs Battery state', + }), + 'context': , + 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4028,7 +4134,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_current-state] +# name: test_platform_setup_and_discovery[sensor.socket3_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -4044,7 +4150,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_power-entry] +# name: test_platform_setup_and_discovery[sensor.socket3_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4084,7 +4190,7 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_power-state] +# name: test_platform_setup_and_discovery[sensor.socket3_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -4100,7 +4206,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_voltage-entry] +# name: test_platform_setup_and_discovery[sensor.socket3_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4143,7 +4249,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][sensor.socket3_voltage-state] +# name: test_platform_setup_and_discovery[sensor.socket3_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -4159,7 +4265,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-entry] +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4196,7 +4302,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery-state] +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -4212,7 +4318,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery_state-entry] +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4247,7 +4353,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][sensor.solar_zijpad_battery_state-state] +# name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Solar zijpad Battery state', @@ -4260,7 +4366,733 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][sensor.wifi_smart_gas_boiler_thermostat_battery-entry] +# name: test_platform_setup_and_discovery[sensor.sous_vide_current_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.sous_vide_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sous Vide Current temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_remaining_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': None, + 'entity_id': 'sensor.sous_vide_remaining_time', + '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': 'Remaining time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmremain_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sous_vide_status', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sous_vide_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Status', + }), + 'context': , + 'entity_id': 'sensor.sous_vide_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.tournesol_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.tournesol_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.codvtvgtjsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.tournesol_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Tournesol Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tournesol_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_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.v20_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.zrrraytdoanz33rldselectricity_left', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'V20 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.v20_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_area-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.v20_cleaning_area', + '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': 'Cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_area', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_time-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.v20_cleaning_time', + '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': 'Cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cleaning_time', + 'unique_id': 'tuya.zrrraytdoanz33rldsclean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_duster_cloth_lifetime-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.v20_duster_cloth_lifetime', + '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': 'Duster cloth lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'duster_cloth_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsduster_cloth', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_duster_cloth_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Duster cloth lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_duster_cloth_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9000.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_filter_lifetime-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.v20_filter_lifetime', + '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': 'Filter lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsfilter', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_filter_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Filter lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_filter_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8956.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_rolling_brush_lifetime-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.v20_rolling_brush_lifetime', + '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': 'Rolling brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rolling_brush_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsroll_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_rolling_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Rolling brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_rolling_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17948.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_side_brush_lifetime-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.v20_side_brush_lifetime', + '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': 'Side brush lifetime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_life', + 'unique_id': 'tuya.zrrraytdoanz33rldsedge_brush', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_side_brush_lifetime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Side brush lifetime', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_side_brush_lifetime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8944.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_area-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.v20_total_cleaning_area', + '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': 'Total cleaning area', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_area', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_area', + 'unit_of_measurement': '㎡', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning area', + 'state_class': , + 'unit_of_measurement': '㎡', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_time-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.v20_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning time', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_times-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.v20_total_cleaning_times', + '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': 'Total cleaning times', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_times', + 'unique_id': 'tuya.zrrraytdoanz33rldstotal_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.v20_total_cleaning_times-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Total cleaning times', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.v20_total_cleaning_times', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4297,7 +5129,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][sensor.wifi_smart_gas_boiler_thermostat_battery-state] +# name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -4313,635 +5145,7 @@ 'state': '100.0', }) # --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswbattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'NP DownStairs North Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.np_downstairs_north_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'NP DownStairs North Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.np_downstairs_north_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '47.0', - }) -# --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_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.np_downstairs_north_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'temperature', - 'unique_id': 'tuya.vayhq2aj3p3z6y2ggcdswva_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[wsdcg_g2y6z3p3ja2qhyav][sensor.np_downstairs_north_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'NP DownStairs North Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.np_downstairs_north_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '18.5', - }) -# --- -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][sensor.bathroom_smart_switch_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.bathroom_smart_switch_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.fvywp3b5mu4zay8lgkxwbattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[wxkg_l8yaz4um5b3pwyvf][sensor.bathroom_smart_switch_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Bathroom Smart Switch Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.bathroom_smart_switch_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_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.smoke_detector_upstairs_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': ' Smoke detector upstairs Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.smoke_detector_upstairs_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery_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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', - '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': 'Battery state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_state', - 'unique_id': 'tuya.jfydgffzmhjed9fgjbwybattery_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[ywbj_gf9dejhmzffgdyfj][sensor.smoke_detector_upstairs_battery_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': ' Smoke detector upstairs Battery state', - }), - 'context': , - 'entity_id': 'sensor.smoke_detector_upstairs_battery_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'low', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-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.rainwater_tank_level_distance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Distance', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'depth', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_depth', - 'unit_of_measurement': 'm', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'Rainwater Tank Level Distance', - 'state_class': , - 'unit_of_measurement': 'm', - }), - 'context': , - 'entity_id': 'sensor.rainwater_tank_level_distance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.455', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-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.rainwater_tank_level_liquid_level', - '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': 'Liquid level', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'liquid_level', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_level_percent', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Rainwater Tank Level Liquid level', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.rainwater_tank_level_liquid_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '36.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_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': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.rainwater_tank_level_liquid_state', - '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': 'Liquid state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'liquid_state', - 'unique_id': 'tuya.fbya6s6rhaoyvl8hqgcwyliquid_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Rainwater Tank Level Liquid state', - }), - 'context': , - 'entity_id': 'sensor.rainwater_tank_level_liquid_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'normal', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-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.house_water_level_distance', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Distance', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'depth', - 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_depth', - 'unit_of_measurement': 'm', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'distance', - 'friendly_name': 'House Water Level Distance', - 'state_class': , - 'unit_of_measurement': 'm', - }), - 'context': , - 'entity_id': 'sensor.house_water_level_distance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.42', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-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.house_water_level_liquid_level', - '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': 'Liquid level', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'liquid_level', - 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_level_percent', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'House Water Level Liquid level', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.house_water_level_liquid_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100.0', - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_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': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.house_water_level_liquid_state', - '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': 'Liquid state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'liquid_state', - 'unique_id': 'tuya.snbu4b3vekhywztwqgcwyliquid_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'House Water Level Liquid state', - }), - 'context': , - 'entity_id': 'sensor.house_water_level_liquid_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'upper_alarm', - }) -# --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_current-entry] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4981,7 +5185,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_current-state] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -4997,7 +5201,7 @@ 'state': '599.552', }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_power-entry] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5037,7 +5241,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_power-state] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -5053,7 +5257,7 @@ 'state': '6.912', }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_voltage-entry] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5093,7 +5297,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_phase_a_voltage-state] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -5109,7 +5313,7 @@ 'state': '52.7', }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_energy-entry] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5149,7 +5353,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_energy-state] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -5165,7 +5369,7 @@ 'state': '1.2', }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_production-entry] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5205,7 +5409,7 @@ 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][sensor.xoca_dac212xc_v2_s1_total_production-state] +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -5221,7 +5425,7 @@ 'state': '0.8', }) # --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5236,7 +5440,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meter_phase_a_current', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5248,36 +5452,39 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase A current', + 'original_name': 'Current', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_a_current', - 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_aelectriccurrent', + 'translation_key': 'current', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_current', 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-state] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Meter Phase A current', + 'friendly_name': '一路带计量磁保持通断器 Current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meter_phase_a_current', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5.62', + 'state': '2.198', }) # --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_power-entry] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5292,63 +5499,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meter_phase_a_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase A power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'phase_a_power', - 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_apower', - 'unit_of_measurement': , - }) -# --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Meter Phase A power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meter_phase_a_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.185', - }) -# --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_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.meter_phase_a_voltage', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5361,136 +5512,35 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase A voltage', + 'original_name': 'Power', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_a_voltage', - 'unique_id': 'tuya.nnqlg0rxryraf8ezbdnzphase_avoltage', - 'unit_of_measurement': , + 'translation_key': 'power', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_power', + 'unit_of_measurement': 'W', }) # --- -# name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_voltage-state] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Meter Phase A voltage', + 'device_class': 'power', + 'friendly_name': '一路带计量磁保持通断器 Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.meter_phase_a_voltage', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '233.8', + 'state': '495.3', }) # --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': 'tuya.uew54dymycjwzbattery_percentage', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Patates Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.patates_battery', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '20.0', - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_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': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.patates_battery_state', - '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': 'Battery state', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_state', - 'unique_id': 'tuya.uew54dymycjwzbattery_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_battery_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Patates Battery state', - }), - 'context': , - 'entity_id': 'sensor.patates_battery_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'low', - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_humidity-entry] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5505,60 +5555,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.patates_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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'humidity', - 'unique_id': 'tuya.uew54dymycjwzhumidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Patates Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.patates_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '97.0', - }) -# --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_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.patates_temperature', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5568,34 +5565,37 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Temperature', + 'original_name': 'Voltage', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'temperature', - 'unique_id': 'tuya.uew54dymycjwztemp_current', - 'unit_of_measurement': , + 'translation_key': 'voltage', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldcur_voltage', + 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[zwjcy_myd45weu][sensor.patates_temperature-state] +# name: test_platform_setup_and_discovery[sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Patates Temperature', + 'device_class': 'voltage', + 'friendly_name': '一路带计量磁保持通断器 Voltage', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.patates_temperature', + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '22.0', + 'state': '231.4', }) # --- diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index 7748d1648d8..b6d4e0a086e 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][siren.aqi-entry] +# name: test_platform_setup_and_discovery[siren.aqi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[co2bj_yrr3eiyiacm31ski][siren.aqi-state] +# name: test_platform_setup_and_discovery[siren.aqi-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'AQI', @@ -48,56 +48,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][siren.siren_veranda-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'siren', - 'entity_category': None, - 'entity_id': 'siren.siren_veranda', - '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': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sgbj_ulv4nnue7gqp0rjk][siren.siren_veranda-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Siren veranda ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'siren.siren_veranda', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-entry] +# name: test_platform_setup_and_discovery[siren.c9-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -132,7 +83,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][siren.c9-state] +# name: test_platform_setup_and_discovery[siren.c9-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'C9', @@ -146,3 +97,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[siren.siren_veranda-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.siren_veranda', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.kjr0pqg7eunn4vlujbgsalarm_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[siren.siren_veranda-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Siren veranda ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.siren_veranda', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 9e1e88babac..67f5316ce0e 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1,2710 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-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': , - 'entity_id': 'switch.lounge_dark_blind_reverse', - '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': 'Reverse', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reverse', - 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol_back', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cl_3r8gc33pnqsxfe1g][switch.lounge_dark_blind_reverse-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Lounge Dark Blind Reverse', - }), - 'context': , - 'entity_id': 'switch.lounge_dark_blind_reverse', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-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': , - 'entity_id': 'switch.dehumidifer_child_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:account-lock', - 'original_name': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.ifzgvpgoodrfw2akscchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifer Child lock', - 'icon': 'mdi:account-lock', - }), - 'context': , - 'entity_id': 'switch.dehumidifer_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_ionizer-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': , - 'entity_id': 'switch.dehumidifer_ionizer', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:atom', - 'original_name': 'Ionizer', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'ionizer', - 'unique_id': 'tuya.ifzgvpgoodrfw2akscanion', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_ka2wfrdoogpvgzfi][switch.dehumidifer_ionizer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifer Ionizer', - 'icon': 'mdi:atom', - }), - 'context': , - 'entity_id': 'switch.dehumidifer_ionizer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][switch.dehumidifier_child_lock-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': , - 'entity_id': 'switch.dehumidifier_child_lock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:account-lock', - 'original_name': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.2myxayqtud9aqbizscchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cs_zibqa9dutqyaxym2][switch.dehumidifier_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier Child lock', - 'icon': 'mdi:account-lock', - }), - 'context': , - 'entity_id': 'switch.dehumidifier_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][switch.smart_odor_eliminator_pro_switch-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.smart_odor_eliminator_pro_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.rl39uwgaqwjwcswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwjwq_agwu93lr][switch.smart_odor_eliminator_pro_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Smart Odor Eliminator-Pro Switch', - }), - 'context': , - 'entity_id': 'switch.smart_odor_eliminator_pro_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_filter_reset-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': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', - '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': 'Filter reset', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'filter_reset', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_filter_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain Filter reset', - }), - 'context': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_power-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.pixi_smart_drinking_fountain_power', - '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': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain Power', - }), - 'context': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-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': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', - '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': 'Reset of water usage days', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reset_of_water_usage_days', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain Reset of water usage days', - }), - 'context': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_uv_sterilization-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': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', - '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': 'UV sterilization', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'uv_sterilization', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_uv_sterilization-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain UV sterilization', - }), - 'context': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_water_pump_reset-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': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', - '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': 'Water pump reset', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'water_pump_reset', - 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cwysj_z3rpyvznfcch99aa][switch.pixi_smart_drinking_fountain_water_pump_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'PIXI Smart Drinking Fountain Water pump reset', - }), - 'context': , - 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[cz_0g1fmqh6d5io7lcn][switch.apollo_light_socket_1-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.apollo_light_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.ncl7oi5d6hqmf1g0zcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_0g1fmqh6d5io7lcn][switch.apollo_light_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Apollo light Socket 1', - }), - 'context': , - 'entity_id': 'switch.apollo_light_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_1-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.hvac_meter_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'HVAC Meter Socket 1', - }), - 'context': , - 'entity_id': 'switch.hvac_meter_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_2-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.hvac_meter_socket_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 2', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_2jxesipczks0kdct][switch.hvac_meter_socket_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'HVAC Meter Socket 2', - }), - 'context': , - 'entity_id': 'switch.hvac_meter_socket_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cz_cuhokdii7ojyw8k2][switch.buitenverlichting_socket_1-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.buitenverlichting_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.2k8wyjo7iidkohuczcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_cuhokdii7ojyw8k2][switch.buitenverlichting_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Buitenverlichting Socket 1', - }), - 'context': , - 'entity_id': 'switch.buitenverlichting_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cz_dntgh2ngvshfxpsz][switch.fakkel_veranda_socket_1-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.fakkel_veranda_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.zspxfhsvgn2hgtndzcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_dntgh2ngvshfxpsz][switch.fakkel_veranda_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'fakkel veranda Socket 1', - }), - 'context': , - 'entity_id': 'switch.fakkel_veranda_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][switch.droger_socket_1-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.droger_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_hj0a5c7ckzzexu8l][switch.droger_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'droger Socket 1', - }), - 'context': , - 'entity_id': 'switch.droger_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_child_lock-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': , - 'entity_id': 'switch.wallwasher_front_child_lock', - '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': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.pdasfna8fswh4a0tzcchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'wallwasher front Child lock', - }), - 'context': , - 'entity_id': 'switch.wallwasher_front_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_socket_1-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.wallwasher_front_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.pdasfna8fswh4a0tzcswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[cz_t0a4hwsf8anfsadp][switch.wallwasher_front_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'wallwasher front Socket 1', - }), - 'context': , - 'entity_id': 'switch.wallwasher_front_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-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': , - 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', - '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': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.fcdadqsiax2gvnt0qldchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '一路带计量磁保持通断器 Child lock', - }), - 'context': , - 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.fcdadqsiax2gvnt0qldswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[dlq_0tnvg2xaisqdadcf][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': '一路带计量磁保持通断器 Switch', - }), - 'context': , - 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[kg_gbm9ata1zrzaez4a][switch.qt_switch_switch_1-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.qt_switch_switch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.a4zeazrz1ata9mbggkswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kg_gbm9ata1zrzaez4a][switch.qt_switch_switch_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'QT-Switch Switch 1', - }), - 'context': , - 'entity_id': 'switch.qt_switch_switch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-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': , - 'entity_id': 'switch.hl400_child_lock', - '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': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjklock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'HL400 Child lock', - }), - 'context': , - 'entity_id': 'switch.hl400_child_lock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-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': , - 'entity_id': 'switch.hl400_ionizer', - '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': 'Ionizer', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'ionizer', - 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkanion', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_ionizer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'HL400 Ionizer', - }), - 'context': , - 'entity_id': 'switch.hl400_ionizer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-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.hl400_power', - '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': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'HL400 Power', - }), - 'context': , - 'entity_id': 'switch.hl400_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-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': , - 'entity_id': 'switch.hl400_uv_sterilization', - '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': 'UV sterilization', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'uv_sterilization', - 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkuv', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_CAjWAxBUZt7QZHfz][switch.hl400_uv_sterilization-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'HL400 UV sterilization', - }), - 'context': , - 'entity_id': 'switch.hl400_uv_sterilization', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-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.bree_power', - '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': 'Power', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power', - 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[kj_yrzylxax1qspdgpp][switch.bree_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Bree Power', - }), - 'context': , - 'entity_id': 'switch.bree_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][switch.tower_fan_ca_407g_smart_ionizer-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.tower_fan_ca_407g_smart_ionizer', - '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': 'Ionizer', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'ionizer', - 'unique_id': 'tuya.lflvu8cazha8af9jskanion', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[ks_j9fa8ahzac8uvlfl][switch.tower_fan_ca_407g_smart_ionizer-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', - }), - 'context': , - 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_arm_beep-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': , - 'entity_id': 'switch.multifunction_alarm_arm_beep', - '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': 'Arm beep', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'arm_beep', - 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_sound', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_arm_beep-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Multifunction alarm Arm beep', - }), - 'context': , - 'entity_id': 'switch.multifunction_alarm_arm_beep', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_siren-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': , - 'entity_id': 'switch.multifunction_alarm_siren', - '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': 'Siren', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'siren', - 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_light', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[mal_gyitctrjj1kefxp2][switch.multifunction_alarm_siren-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Multifunction alarm Siren', - }), - 'context': , - 'entity_id': 'switch.multifunction_alarm_siren', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-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': , - 'entity_id': 'switch.sous_vide_start', - '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': 'Start', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'start', - 'unique_id': 'tuya.hyda5jsihokacvaqjzmstart', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sous Vide Start', - }), - 'context': , - 'entity_id': 'switch.sous_vide_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-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.bubbelbad_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Bubbelbad Socket 1', - }), - 'context': , - 'entity_id': 'switch.bubbelbad_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_2-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.bubbelbad_socket_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 2', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Bubbelbad Socket 2', - }), - 'context': , - 'entity_id': 'switch.bubbelbad_socket_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_1-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.terras_socket_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Terras Socket 1', - }), - 'context': , - 'entity_id': 'switch.terras_socket_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_2-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.terras_socket_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Socket 2', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_socket', - 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[pc_trjopo1vdlt9q1tg][switch.terras_socket_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Terras Socket 2', - }), - 'context': , - 'entity_id': 'switch.terras_socket_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[qccdz_7bvgooyjhiua1yyq][switch.ac_charging_control_box_switch-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.ac_charging_control_box_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.qyy1auihjyoogvb7zdccqswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[qccdz_7bvgooyjhiua1yyq][switch.ac_charging_control_box_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC charging control box Switch', - }), - 'context': , - 'entity_id': 'switch.ac_charging_control_box_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-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': , - 'entity_id': 'switch.v20_do_not_disturb', - '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': 'Do not disturb', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'do_not_disturb', - 'unique_id': 'tuya.zrrraytdoanz33rldsswitch_disturb', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][switch.v20_do_not_disturb-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'V20 Do not disturb', - }), - 'context': , - 'entity_id': 'switch.v20_do_not_disturb', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-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.sprinkler_cesare_switch', - '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': 'Switch', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'switch', - 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sfkzq_o6dagifntoafakst][switch.sprinkler_cesare_switch-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Sprinkler Cesare Switch', - }), - 'context': , - 'entity_id': 'switch.sprinkler_cesare_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_flip-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': , - 'entity_id': 'switch.cam_garage_flip', - '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': 'Flip', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'flip', - 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_flip', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_flip-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Flip', - }), - 'context': , - 'entity_id': 'switch.cam_garage_flip', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_motion_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': 'switch', - 'entity_category': , - 'entity_id': 'switch.cam_garage_motion_alarm', - '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': 'Motion alarm', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_motion_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Motion alarm', - }), - 'context': , - 'entity_id': 'switch.cam_garage_motion_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_sound_detection-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': , - 'entity_id': 'switch.cam_garage_sound_detection', - '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': 'Sound detection', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sound_detection', - 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_sound_detection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Sound detection', - }), - 'context': , - 'entity_id': 'switch.cam_garage_sound_detection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_time_watermark-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': , - 'entity_id': 'switch.cam_garage_time_watermark', - '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': 'Time watermark', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'time_watermark', - 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_osd', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_time_watermark-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Time watermark', - }), - 'context': , - 'entity_id': 'switch.cam_garage_time_watermark', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_video_recording-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': , - 'entity_id': 'switch.cam_garage_video_recording', - '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': 'Video recording', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'video_recording', - 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_drezasavompxpcgm][switch.cam_garage_video_recording-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM GARAGE Video recording', - }), - 'context': , - 'entity_id': 'switch.cam_garage_video_recording', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_flip-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': , - 'entity_id': 'switch.cam_porch_flip', - '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': 'Flip', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'flip', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_flip', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_flip-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Flip', - }), - 'context': , - 'entity_id': 'switch.cam_porch_flip', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_motion_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': 'switch', - 'entity_category': , - 'entity_id': 'switch.cam_porch_motion_alarm', - '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': 'Motion alarm', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_motion_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Motion alarm', - }), - 'context': , - 'entity_id': 'switch.cam_porch_motion_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_sound_detection-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': , - 'entity_id': 'switch.cam_porch_sound_detection', - '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': 'Sound detection', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'sound_detection', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_sound_detection-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Sound detection', - }), - 'context': , - 'entity_id': 'switch.cam_porch_sound_detection', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_time_watermark-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': , - 'entity_id': 'switch.cam_porch_time_watermark', - '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': 'Time watermark', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'time_watermark', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_osd', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_time_watermark-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Time watermark', - }), - 'context': , - 'entity_id': 'switch.cam_porch_time_watermark', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_video_recording-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': , - 'entity_id': 'switch.cam_porch_video_recording', - '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': 'Video recording', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'video_recording', - 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_rjKXWRohlvOTyLBu][switch.cam_porch_video_recording-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'CAM PORCH Video recording', - }), - 'context': , - 'entity_id': 'switch.cam_porch_video_recording', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-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': , - 'entity_id': 'switch.c9_flip', - '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': 'Flip', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'flip', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_flip', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_flip-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Flip', - }), - 'context': , - 'entity_id': 'switch.c9_flip', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_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': 'switch', - 'entity_category': , - 'entity_id': 'switch.c9_motion_alarm', - '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': 'Motion alarm', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_alarm', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_alarm-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Motion alarm', - }), - 'context': , - 'entity_id': 'switch.c9_motion_alarm', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-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': , - 'entity_id': 'switch.c9_motion_recording', - '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': 'Motion recording', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_recording', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_record', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_recording-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Motion recording', - }), - 'context': , - 'entity_id': 'switch.c9_motion_recording', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-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': , - 'entity_id': 'switch.c9_motion_tracking', - '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': 'Motion tracking', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'motion_tracking', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_tracking', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_motion_tracking-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Motion tracking', - }), - 'context': , - 'entity_id': 'switch.c9_motion_tracking', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-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': , - 'entity_id': 'switch.c9_time_watermark', - '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': 'Time watermark', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'time_watermark', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_osd', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_time_watermark-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Time watermark', - }), - 'context': , - 'entity_id': 'switch.c9_time_watermark', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-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': , - 'entity_id': 'switch.c9_video_recording', - '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': 'Video recording', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'video_recording', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_video_recording-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Video recording', - }), - 'context': , - 'entity_id': 'switch.c9_video_recording', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-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': , - 'entity_id': 'switch.c9_wide_dynamic_range', - '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': 'Wide dynamic range', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wide_dynamic_range', - 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_wdr', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[sp_sdd5f5f2dl5wydjf][switch.c9_wide_dynamic_range-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'C9 Wide dynamic range', - }), - 'context': , - 'entity_id': 'switch.c9_wide_dynamic_range', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-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.jardin_fraises_switch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_1aegphq4yfd50e6b][switch.jardin_fraises_switch_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'jardin Fraises Switch 1', - }), - 'context': , - 'entity_id': 'switch.jardin_fraises_switch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-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.framboisiers_switch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_9htyiowaf5rtdhrv][switch.framboisiers_switch_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Framboisiers Switch 1', - }), - 'context': , - 'entity_id': 'switch.framboisiers_switch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-entry] +# name: test_platform_setup_and_discovery[switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2739,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_1-state] +# name: test_platform_setup_and_discovery[switch.4_433_switch_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -2753,7 +48,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_2-entry] +# name: test_platform_setup_and_discovery[switch.4_433_switch_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2788,7 +83,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_2-state] +# name: test_platform_setup_and_discovery[switch.4_433_switch_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -2802,7 +97,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_3-entry] +# name: test_platform_setup_and_discovery[switch.4_433_switch_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2837,7 +132,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_3-state] +# name: test_platform_setup_and_discovery[switch.4_433_switch_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -2851,7 +146,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_4-entry] +# name: test_platform_setup_and_discovery[switch.4_433_switch_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2886,7 +181,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_cq1p0nt0a4rixnex][switch.4_433_switch_4-state] +# name: test_platform_setup_and_discovery[switch.4_433_switch_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', @@ -2900,7 +195,7 @@ 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_child_lock-entry] +# name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2912,8 +207,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'entity_category': None, + 'entity_id': 'switch.ac_charging_control_box_switch', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2925,30 +220,30 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Child lock', + 'original_name': 'Switch', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtchild_lock', + 'translation_key': 'switch', + 'unique_id': 'tuya.qyy1auihjyoogvb7zdccqswitch', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_child_lock-state] +# name: test_platform_setup_and_discovery[switch.ac_charging_control_box_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Seating side 6-ch Smart Switch Child lock', + 'friendly_name': 'AC charging control box Switch', }), 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'entity_id': 'switch.ac_charging_control_box_switch', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-entry] +# name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2961,7 +256,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'entity_id': 'switch.apollo_light_socket_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2973,325 +268,31 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Switch 1', + 'original_name': 'Socket 1', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_1', + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.ncl7oi5d6hqmf1g0zcswitch_1', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_1-state] +# name: test_platform_setup_and_discovery[switch.apollo_light_socket_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 1', + 'friendly_name': 'Apollo light Socket 1', }), 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-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.seating_side_6_ch_smart_switch_switch_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 2', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_2', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 2', - }), - 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-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.seating_side_6_ch_smart_switch_switch_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 3', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_3', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 3', - }), - 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-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.seating_side_6_ch_smart_switch_switch_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 4', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_4', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 4', - }), - 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-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.seating_side_6_ch_smart_switch_switch_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 5', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_5', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 5', - }), - 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-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.seating_side_6_ch_smart_switch_switch_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 6', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_6', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_nockvv2k39vbrxxk][switch.seating_side_6_ch_smart_switch_switch_6-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Seating side 6-ch Smart Switch Switch 6', - }), - 'context': , - 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-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.socket3_switch_1', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Switch 1', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'indexed_switch', - 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtswitch_1', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[tdq_pu8uhxhwcp3tgoz7][switch.socket3_switch_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Socket3 Switch 1', - }), - 'context': , - 'entity_id': 'switch.socket3_switch_1', + 'entity_id': 'switch.apollo_light_socket_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unavailable', }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-entry] +# name: test_platform_setup_and_discovery[switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3303,8 +304,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.solar_zijpad_energy_saving', + 'entity_category': None, + 'entity_id': 'switch.bree_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3316,78 +317,993 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Energy saving', + 'original_name': 'Power', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_saving', - 'unique_id': 'tuya.couukaypjdnytswitch_save_energy', + 'translation_key': 'power', + 'unique_id': 'tuya.ppgdpsq1xaxlyzryjkswitch', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[tyndj_pyakuuoc][switch.solar_zijpad_energy_saving-state] +# name: test_platform_setup_and_discovery[switch.bree_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Solar zijpad Energy saving', + 'friendly_name': 'Bree Power', }), 'context': , - 'entity_id': 'switch.solar_zijpad_energy_saving', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][switch.kabinet_child_lock-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': , - 'entity_id': 'switch.kabinet_child_lock', - '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': 'Child lock', - 'platform': 'tuya', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'child_lock', - 'unique_id': 'tuya.dn7cjik6kwchild_lock', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[wk_6kijc7nd][switch.kabinet_child_lock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Кабінет Child lock', - }), - 'context': , - 'entity_id': 'switch.kabinet_child_lock', + 'entity_id': 'switch.bree_power', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_aqoouq7x][switch.clima_cucina_child_lock-entry] +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_1-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.bubbelbad_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 1', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_2-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.bubbelbad_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pfhwb1v3i7cifa2tcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bubbelbad_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bubbelbad Socket 2', + }), + 'context': , + 'entity_id': 'switch.bubbelbad_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.buitenverlichting_socket_1-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.buitenverlichting_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.2k8wyjo7iidkohuczcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.buitenverlichting_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Buitenverlichting Socket 1', + }), + 'context': , + 'entity_id': 'switch.buitenverlichting_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_flip-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': , + 'entity_id': 'switch.c9_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Flip', + }), + 'context': , + 'entity_id': 'switch.c9_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.c9_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion alarm', + }), + 'context': , + 'entity_id': 'switch.c9_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_recording-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': , + 'entity_id': 'switch.c9_motion_recording', + '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': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion recording', + }), + 'context': , + 'entity_id': 'switch.c9_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_tracking-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': , + 'entity_id': 'switch.c9_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Motion tracking', + }), + 'context': , + 'entity_id': 'switch.c9_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_time_watermark-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': , + 'entity_id': 'switch.c9_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Time watermark', + }), + 'context': , + 'entity_id': 'switch.c9_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_video_recording-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': , + 'entity_id': 'switch.c9_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Video recording', + }), + 'context': , + 'entity_id': 'switch.c9_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_wide_dynamic_range-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': , + 'entity_id': 'switch.c9_wide_dynamic_range', + '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': 'Wide dynamic range', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wide_dynamic_range', + 'unique_id': 'tuya.fjdyw5ld2f5f5ddspsbasic_wdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.c9_wide_dynamic_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'C9 Wide dynamic range', + }), + 'context': , + 'entity_id': 'switch.c9_wide_dynamic_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_flip-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': , + 'entity_id': 'switch.cam_garage_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Flip', + }), + 'context': , + 'entity_id': 'switch.cam_garage_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_garage_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.mgcpxpmovasazerdpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_garage_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_sound_detection-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': , + 'entity_id': 'switch.cam_garage_sound_detection', + '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': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.mgcpxpmovasazerdpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_garage_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_time_watermark-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': , + 'entity_id': 'switch.cam_garage_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.mgcpxpmovasazerdpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_garage_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_video_recording-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': , + 'entity_id': 'switch.cam_garage_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.mgcpxpmovasazerdpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_garage_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM GARAGE Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_garage_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_flip-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': , + 'entity_id': 'switch.cam_porch_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Flip', + }), + 'context': , + 'entity_id': 'switch.cam_porch_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.cam_porch_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Motion alarm', + }), + 'context': , + 'entity_id': 'switch.cam_porch_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_sound_detection-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': , + 'entity_id': 'switch.cam_porch_sound_detection', + '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': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Sound detection', + }), + 'context': , + 'entity_id': 'switch.cam_porch_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_time_watermark-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': , + 'entity_id': 'switch.cam_porch_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Time watermark', + }), + 'context': , + 'entity_id': 'switch.cam_porch_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_video_recording-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': , + 'entity_id': 'switch.cam_porch_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.uBLyTOvlhoRWXKjrpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.cam_porch_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'CAM PORCH Video recording', + }), + 'context': , + 'entity_id': 'switch.cam_porch_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.clima_cucina_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3422,7 +1338,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_aqoouq7x][switch.clima_cucina_child_lock-state] +# name: test_platform_setup_and_discovery[switch.clima_cucina_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Clima cucina Child lock', @@ -3435,7 +1351,7 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] +# name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3448,7 +1364,301 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'entity_id': 'switch.dehumidifer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_ionizer-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': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.ifzgvpgoodrfw2akscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifer_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifier_child_lock-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': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.2myxayqtud9aqbizscchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.droger_socket_1-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.droger_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.l8uxezzkc7c5a0jhzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.droger_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'droger Socket 1', + }), + 'context': , + 'entity_id': 'switch.droger_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-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.fakkel_veranda_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zspxfhsvgn2hgtndzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'fakkel veranda Socket 1', + }), + 'context': , + 'entity_id': 'switch.fakkel_veranda_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisiers_switch_1-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.framboisiers_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.vrhdtr5fawoiyth9qdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisiers_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisiers Switch 1', + }), + 'context': , + 'entity_id': 'switch.framboisiers_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_child_lock-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': , + 'entity_id': 'switch.hl400_child_lock', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3466,24 +1676,1186 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'tuya.j6mn1t4ut5end6ifkwchild_lock', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjklock', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_fi6dne5tu4t1nm6j][switch.wifi_smart_gas_boiler_thermostat_child_lock-state] +# name: test_platform_setup_and_discovery[switch.hl400_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock', + 'friendly_name': 'HL400 Child lock', }), 'context': , - 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'entity_id': 'switch.hl400_child_lock', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][switch.smart_thermostats_child_lock-entry] +# name: test_platform_setup_and_discovery[switch.hl400_ionizer-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': , + 'entity_id': 'switch.hl400_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Ionizer', + }), + 'context': , + 'entity_id': 'switch.hl400_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_power-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.hl400_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 Power', + }), + 'context': , + 'entity_id': 'switch.hl400_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_uv_sterilization-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': , + 'entity_id': 'switch.hl400_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hl400_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 UV sterilization', + }), + 'context': , + 'entity_id': 'switch.hl400_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-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.hvac_meter_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 1', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_2-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.hvac_meter_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.tcdk0skzcpisexj2zcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hvac_meter_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 2', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.jardin_fraises_switch_1-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.jardin_fraises_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.b6e05dfy4qhpgea1qdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.jardin_fraises_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'jardin Fraises Switch 1', + }), + 'context': , + 'entity_id': 'switch.jardin_fraises_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_child_lock-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': , + 'entity_id': 'switch.kabinet_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.dn7cjik6kwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Child lock', + }), + 'context': , + 'entity_id': 'switch.kabinet_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-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': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + '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': 'Reverse', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reverse', + 'unique_id': 'tuya.g1efxsqnp33cg8r3lccontrol_back', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lounge Dark Blind Reverse', + }), + 'context': , + 'entity_id': 'switch.lounge_dark_blind_reverse', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_arm_beep-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': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + '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': 'Arm beep', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_beep', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_arm_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Arm beep', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_siren-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': , + 'entity_id': 'switch.multifunction_alarm_siren', + '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': 'Siren', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': 'tuya.2pxfek1jjrtctiyglamswitch_alarm_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.multifunction_alarm_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Siren', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_filter_reset-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': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', + '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': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Filter reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_power-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.pixi_smart_drinking_fountain_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Power', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-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': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', + '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': 'Reset of water usage days', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_of_water_usage_days', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcwater_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Reset of water usage days', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_uv_sterilization-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': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain UV sterilization', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_water_pump_reset-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': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', + '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': 'Water pump reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pump_reset', + 'unique_id': 'tuya.aa99hccfnzvypr3zjsywcpump_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_water_pump_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-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.qt_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.a4zeazrz1ata9mbggkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'QT-Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.qt_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-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': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seating side 6-ch Smart Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_1-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.seating_side_6_ch_smart_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_2-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.seating_side_6_ch_smart_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_3-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.seating_side_6_ch_smart_switch_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 3', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_4-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.seating_side_6_ch_smart_switch_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 4', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_5-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.seating_side_6_ch_smart_switch_switch_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 5', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_6-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.seating_side_6_ch_smart_switch_switch_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 6', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.kxxrbv93k2vvkconqdtswitch_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_switch_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Seating side 6-ch Smart Switch Switch 6', + }), + 'context': , + 'entity_id': 'switch.seating_side_6_ch_smart_switch_switch_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-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.smart_odor_eliminator_pro_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.rl39uwgaqwjwcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Switch', + }), + 'context': , + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3518,7 +2890,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_gogb05wrtredz3bs][switch.smart_thermostats_child_lock-state] +# name: test_platform_setup_and_discovery[switch.smart_thermostats_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'smart thermostats Child lock', @@ -3531,7 +2903,200 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][switch.term_prizemi_child_lock-entry] +# name: test_platform_setup_and_discovery[switch.socket3_switch_1-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.socket3_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket3_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket3 Switch 1', + }), + 'context': , + 'entity_id': 'switch.socket3_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.solar_zijpad_energy_saving-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': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + '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 saving', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saving', + 'unique_id': 'tuya.couukaypjdnytswitch_save_energy', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.solar_zijpad_energy_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Solar zijpad Energy saving', + }), + 'context': , + 'entity_id': 'switch.solar_zijpad_energy_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sous_vide_start-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': , + 'entity_id': 'switch.sous_vide_start', + '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': 'Start', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'tuya.hyda5jsihokacvaqjzmstart', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sous_vide_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Start', + }), + 'context': , + 'entity_id': 'switch.sous_vide_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-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.sprinkler_cesare_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.tskafaotnfigad6oqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sprinkler Cesare Switch', + }), + 'context': , + 'entity_id': 'switch.sprinkler_cesare_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.term_prizemi_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3566,7 +3131,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[wk_y5obtqhuztqsf2mj][switch.term_prizemi_child_lock-state] +# name: test_platform_setup_and_discovery[switch.term_prizemi_child_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Term - Prizemi Child lock', @@ -3579,7 +3144,346 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][switch.xoca_dac212xc_v2_s1_switch-entry] +# name: test_platform_setup_and_discovery[switch.terras_socket_1-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.terras_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 1', + }), + 'context': , + 'entity_id': 'switch.terras_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_2-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.terras_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.gt1q9tldv1opojrtcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.terras_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Terras Socket 2', + }), + 'context': , + 'entity_id': 'switch.terras_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.tower_fan_ca_407g_smart_ionizer-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.tower_fan_ca_407g_smart_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.lflvu8cazha8af9jskanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.tower_fan_ca_407g_smart_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', + }), + 'context': , + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.v20_do_not_disturb-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': , + 'entity_id': 'switch.v20_do_not_disturb', + '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': 'Do not disturb', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'do_not_disturb', + 'unique_id': 'tuya.zrrraytdoanz33rldsswitch_disturb', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.v20_do_not_disturb-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'V20 Do not disturb', + }), + 'context': , + 'entity_id': 'switch.v20_do_not_disturb', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-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': , + 'entity_id': 'switch.wallwasher_front_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'wallwasher front Child lock', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_socket_1-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.wallwasher_front_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pdasfna8fswh4a0tzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wallwasher_front_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'wallwasher front Socket 1', + }), + 'context': , + 'entity_id': 'switch.wallwasher_front_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-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': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock', + }), + 'context': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.xoca_dac212xc_v2_s1_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3614,7 +3518,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[zndb_4ggkyflayu1h1ho9][switch.xoca_dac212xc_v2_s1_switch-state] +# name: test_platform_setup_and_discovery[switch.xoca_dac212xc_v2_s1_switch-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'XOCA-DAC212XC V2-S1 Switch', @@ -3627,3 +3531,99 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-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': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Child lock', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.fcdadqsiax2gvnt0qldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Switch', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr index e75e33af002..fe0b2fbce97 100644 --- a/tests/components/tuya/snapshots/test_vacuum.ambr +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-entry] +# name: test_platform_setup_and_discovery[vacuum.v20-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -40,7 +40,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][vacuum.v20-state] +# name: test_platform_setup_and_discovery[vacuum.v20-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'fan_speed': 'strong', diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py index 71527bd83eb..53721b1add0 100644 --- a/tests/components/tuya/test_alarm_control_panel.py +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,45 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index 85dd644b79c..4da79effde7 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -13,51 +13,27 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, MockDeviceListener, initialize_entry +from . import MockDeviceListener, initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.BINARY_SENSOR in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.BINARY_SENSOR not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["cs_zibqa9dutqyaxym2"], diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py index b8c6dda4afa..e9a7b43e103 100644 --- a/tests/components/tuya/test_button.py +++ b/tests/components/tuya/test_button.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,45 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.BUTTON not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py index 25bfe57ea0c..94295fe1191 100644 --- a/tests/components/tuya/test_camera.py +++ b/tests/components/tuya/test_camera.py @@ -13,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform @@ -28,22 +28,18 @@ def mock_getrandbits(): yield -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform( hass, @@ -51,23 +47,3 @@ async def test_platform_setup_and_discovery( snapshot, mock_config_entry.entry_id, ) - - -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.CAMERA not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CAMERA]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 01fdf469e27..47c59267881 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -20,50 +20,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["kt_5wnlzekkstwcdsvm"], diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 20d84878a58..5b4610a6875 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -21,50 +21,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.COVER in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.COVER not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["cl_zah67ekd"], diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py index 3a332dbe5c7..6e493ae41c0 100644 --- a/tests/components/tuya/test_event.py +++ b/tests/components/tuya/test_event.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,45 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index f6b9a6956bf..992c989e352 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,43 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.FAN in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.FAN not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index bd3604b25dd..debfb765d8b 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -20,49 +20,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.HUMIDIFIER in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.HUMIDIFIER not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["cs_zibqa9dutqyaxym2"], diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index e3586613876..008d918cee1 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -18,50 +18,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["dj_mki13ie507rlry4r"], diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index f28d6414170..58cfe3635ea 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -15,48 +15,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.NUMBER in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.NUMBER not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["mal_gyitctrjj1kefxp2"], diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index 475fab30b90..ed585e4568f 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -18,48 +18,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SELECT in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SELECT not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["cl_zah67ekd"], diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index d0c6054c135..a5d61ea47a6 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -13,44 +13,22 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SENSOR in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SENSOR not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index 69ccc14e407..1043c0a3a0f 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,43 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 6164a5c7af8..e763fe3bd91 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice @@ -13,43 +12,21 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SWITCH in v] -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - -@pytest.mark.parametrize( - "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SWITCH not in v] -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index 1caf298f3c4..5098927d1b4 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -17,50 +17,26 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import DEVICE_MOCKS, initialize_entry +from . import initialize_entry from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM in v], -) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, + mock_devices: list[CustomerDevice], entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test platform setup and discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - "mock_device_code", - [k for k, v in DEVICE_MOCKS.items() if Platform.VACUUM not in v], -) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VACUUM]) -async def test_platform_setup_no_discovery( - hass: HomeAssistant, - mock_manager: ManagerCompat, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, - entity_registry: er.EntityRegistry, -) -> None: - """Test platform setup without discovery.""" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - assert not er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - @pytest.mark.parametrize( "mock_device_code", ["sd_lr33znaodtyarrrz"], From acb58c41ebabd10c79011a053b2ae14e5e2f6523 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 9 Aug 2025 07:48:05 +0200 Subject: [PATCH 1052/1118] Bump airOS to 0.2.7 supporting firmware 8.7.11 (#150298) --- homeassistant/components/airos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index b9bd2db1ae4..84003c19b89 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airos", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["airos==0.2.6"] + "requirements": ["airos==0.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a876b41b8db..c9fdd9d1a55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.6 +airos==0.2.7 # homeassistant.components.airthings_ble airthings-ble==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7059e5691ab..73a712dcf3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -435,7 +435,7 @@ airgradient==0.9.2 airly==1.1.0 # homeassistant.components.airos -airos==0.2.6 +airos==0.2.7 # homeassistant.components.airthings_ble airthings-ble==0.9.2 From ff72faf83adaefea1b36cd69705a3de9deb01403 Mon Sep 17 00:00:00 2001 From: steinmn <46349253+steinmn@users.noreply.github.com> Date: Sat, 9 Aug 2025 07:48:49 +0200 Subject: [PATCH 1053/1118] Set suggested display precision on Volvo energy/fuel consumption sensors (#150296) --- homeassistant/components/volvo/sensor.py | 5 +++++ tests/components/volvo/snapshots/test_sensor.ambr | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index caadebb6e2a..a067549f068 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -114,6 +114,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( api_field="averageEnergyConsumption", native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # statistics endpoint VolvoSensorDescription( @@ -121,6 +122,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( api_field="averageEnergyConsumptionAutomatic", native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # statistics endpoint VolvoSensorDescription( @@ -128,6 +130,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( api_field="averageEnergyConsumptionSinceCharge", native_unit_of_measurement=UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # statistics endpoint VolvoSensorDescription( @@ -135,6 +138,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( api_field="averageFuelConsumption", native_unit_of_measurement="L/100 km", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # statistics endpoint VolvoSensorDescription( @@ -142,6 +146,7 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = ( api_field="averageFuelConsumptionAutomatic", native_unit_of_measurement="L/100 km", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), # statistics endpoint VolvoSensorDescription( diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 53e05c49c97..487514cd6c3 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -1056,6 +1056,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': None, 'original_icon': None, @@ -1788,6 +1791,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': None, 'original_icon': None, @@ -2985,6 +2991,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': None, 'original_icon': None, @@ -3717,6 +3726,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': None, 'original_icon': None, From fb64ff1d17530b78bb5a24ae06d11961c6d194b2 Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Sat, 9 Aug 2025 12:14:31 +0200 Subject: [PATCH 1054/1118] Update knx-frontend to 2025.8.9.63154 (#150323) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f3013de4556..312ea56972f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", - "knx-frontend==2025.8.6.52906" + "knx-frontend==2025.8.9.63154" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c9fdd9d1a55..f3ee946e443 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1307,7 +1307,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.6.52906 +knx-frontend==2025.8.9.63154 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73a712dcf3d..4d26a571437 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1129,7 +1129,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.8.6.52906 +knx-frontend==2025.8.9.63154 # homeassistant.components.konnected konnected==1.2.0 From f8d3bc1b891e5116bd041b3544d845fd01a04ae1 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Sat, 9 Aug 2025 12:24:53 +0200 Subject: [PATCH 1055/1118] Volvo: Skip unsupported API fields (#150285) --- homeassistant/components/volvo/coordinator.py | 19 +- tests/components/volvo/__init__.py | 6 + .../xc60_phev_2020/energy_capabilities.json | 33 + .../fixtures/xc60_phev_2020/energy_state.json | 52 + .../fixtures/xc60_phev_2020/statistics.json | 32 + .../fixtures/xc60_phev_2020/vehicle.json | 17 + .../volvo/snapshots/test_sensor.ambr | 1026 +++++++++++++++-- tests/components/volvo/test_sensor.py | 25 +- 8 files changed, 1096 insertions(+), 114 deletions(-) create mode 100644 tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json create mode 100644 tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json create mode 100644 tests/components/volvo/fixtures/xc60_phev_2020/statistics.json create mode 100644 tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json diff --git a/homeassistant/components/volvo/coordinator.py b/homeassistant/components/volvo/coordinator.py index 8ddaaee0781..da23e7875c9 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -15,6 +15,7 @@ from volvocarsapi.models import ( VolvoAuthException, VolvoCarsApiBaseModel, VolvoCarsValue, + VolvoCarsValueStatusField, VolvoCarsVehicle, ) @@ -36,6 +37,16 @@ type VolvoConfigEntry = ConfigEntry[tuple[VolvoBaseCoordinator, ...]] type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None] +def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool: + if not field: + return True + + if isinstance(field, VolvoCarsValueStatusField) and field.status == "ERROR": + return True + + return False + + class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): """Volvo base coordinator.""" @@ -121,7 +132,13 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]): translation_key="update_failed", ) from result - data |= cast(CoordinatorData, result) + api_data = cast(CoordinatorData, result) + data |= { + key: field + for key, field in api_data.items() + if not _is_invalid_api_field(field) + } + valid = True # Raise an error if not a single API call succeeded diff --git a/tests/components/volvo/__init__.py b/tests/components/volvo/__init__.py index 875052fcf7e..acd608b8d26 100644 --- a/tests/components/volvo/__init__.py +++ b/tests/components/volvo/__init__.py @@ -20,6 +20,12 @@ _MODEL_SPECIFIC_RESPONSES = { "statistics", "vehicle", ], + "xc60_phev_2020": [ + "energy_capabilities", + "energy_state", + "statistics", + "vehicle", + ], "xc90_petrol_2019": ["commands", "statistics", "vehicle"], } diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json new file mode 100644 index 00000000000..d8aa07ff0bb --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_capabilities.json @@ -0,0 +1,33 @@ +{ + "isSupported": true, + "batteryChargeLevel": { + "isSupported": false + }, + "electricRange": { + "isSupported": false + }, + "chargerConnectionStatus": { + "isSupported": true + }, + "chargingSystemStatus": { + "isSupported": true + }, + "chargingType": { + "isSupported": false + }, + "chargerPowerStatus": { + "isSupported": false + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "isSupported": false + }, + "targetBatteryChargeLevel": { + "isSupported": true + }, + "chargingCurrentLimit": { + "isSupported": false + }, + "chargingPower": { + "isSupported": false + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json b/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json new file mode 100644 index 00000000000..e2f0cd13807 --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/energy_state.json @@ -0,0 +1,52 @@ +{ + "batteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "electricRange": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargerConnectionStatus": { + "status": "OK", + "value": "DISCONNECTED", + "updatedAt": "2025-08-07T20:29:18Z" + }, + "chargingStatus": { + "status": "OK", + "value": "IDLE", + "updatedAt": "2025-08-07T20:29:18Z" + }, + "chargingType": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargerPowerStatus": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "estimatedChargingTimeToTargetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargingCurrentLimit": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "targetBatteryChargeLevel": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + }, + "chargingPower": { + "status": "ERROR", + "code": "NOT_SUPPORTED", + "message": "Resource is not supported for this vehicle" + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json b/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json new file mode 100644 index 00000000000..91384f2d13e --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/statistics.json @@ -0,0 +1,32 @@ +{ + "averageFuelConsumption": { + "value": 4.0, + "unit": "l/100km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "averageSpeed": { + "value": 65, + "unit": "km/h", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "tripMeterManual": { + "value": 219.7, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "tripMeterAutomatic": { + "value": 0.0, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "distanceToEmptyTank": { + "value": 920, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + }, + "distanceToEmptyBattery": { + "value": 29, + "unit": "km", + "timestamp": "2025-08-07T20:29:18.343Z" + } +} diff --git a/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json b/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json new file mode 100644 index 00000000000..734672eb59e --- /dev/null +++ b/tests/components/volvo/fixtures/xc60_phev_2020/vehicle.json @@ -0,0 +1,17 @@ +{ + "vin": "YV1ABCDEFG1234567", + "modelYear": 2020, + "gearbox": "AUTOMATIC", + "fuelType": "PETROL/ELECTRIC", + "externalColour": "Bright Silver", + "batteryCapacityKWH": 11.832, + "images": { + "exteriorImageUrl": "https://cas.volvocars.com/image/dynamic/MY20_0000/123/exterior-v1/_/default.png?market=se&client=public-api-engineering&angle=1&bg=00000000&w=1920", + "internalImageUrl": "https://cas.volvocars.com/image/dynamic/MY20_0000/123/interior-v1/_/default.jpg?market=se&client=public-api-engineering&angle=0&w=1920" + }, + "descriptions": { + "model": "XC60", + "upholstery": "CHARCOAL/LEABR3/CHARC/SPO", + "steering": "LEFT" + } +} diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 487514cd6c3..29e7e1e72a5 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -232,118 +232,6 @@ 'state': 'connected', }) # --- -# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-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.volvo_ex30_charging_limit', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging limit', - 'platform': 'volvo', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charging_current_limit', - 'unique_id': 'yv1abcdefg1234567_charging_current_limit', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_limit-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'current', - 'friendly_name': 'Volvo EX30 Charging limit', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.volvo_ex30_charging_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- -# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_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.volvo_ex30_charging_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging power', - 'platform': 'volvo', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charging_power', - 'unique_id': 'yv1abcdefg1234567_charging_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Volvo EX30 Charging power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.volvo_ex30_charging_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_sensor[ex30_2024][sensor.volvo_ex30_charging_power_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3134,6 +3022,920 @@ 'state': '3822.9', }) # --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_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.volvo_xc60_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'yv1abcdefg1234567_battery_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Volvo XC60 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87.3', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery_capacity-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.volvo_xc60_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery capacity', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_capacity', + 'unique_id': 'yv1abcdefg1234567_battery_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy_storage', + 'friendly_name': 'Volvo XC60 Battery capacity', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.832', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_car_connection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_car_connection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Car connection', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'availability', + 'unique_id': 'yv1abcdefg1234567_availability', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_car_connection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Car connection', + 'options': list([ + 'available', + 'car_in_use', + 'no_internet', + 'ota_installation_in_progress', + 'power_saving_mode', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_car_connection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_charging_connection_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 connection status', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_connection_status', + 'unique_id': 'yv1abcdefg1234567_charger_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Charging connection status', + 'options': list([ + 'connected', + 'disconnected', + 'fault', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_charging_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disconnected', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.volvo_xc60_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': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'yv1abcdefg1234567_charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Volvo XC60 Charging status', + 'options': list([ + 'charging', + 'discharging', + 'done', + 'error', + 'idle', + 'scheduled', + ]), + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_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.volvo_xc60_distance_to_empty_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty battery', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_battery', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to empty battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_tank-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.volvo_xc60_distance_to_empty_tank', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to empty tank', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_empty_tank', + 'unique_id': 'yv1abcdefg1234567_distance_to_empty_tank', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_empty_tank-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to empty tank', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_empty_tank', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '920', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_service-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.volvo_xc60_distance_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'distance_to_service', + 'unique_id': 'yv1abcdefg1234567_distance_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_distance_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Distance to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_distance_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29000', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_fuel_amount-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.volvo_xc60_fuel_amount', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fuel amount', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fuel_amount', + 'unique_id': 'yv1abcdefg1234567_fuel_amount', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_fuel_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Volvo XC60 Fuel amount', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_fuel_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.3', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_odometer-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.volvo_xc60_odometer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Odometer', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odometer', + 'unique_id': 'yv1abcdefg1234567_odometer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_odometer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Odometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_odometer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30000', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_engine_service-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.volvo_xc60_time_to_engine_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to engine service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_time_to_service', + 'unique_id': 'yv1abcdefg1234567_engine_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_engine_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC60 Time to engine service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_time_to_engine_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1266', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_service-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.volvo_xc60_time_to_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to service', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_to_service', + 'unique_id': 'yv1abcdefg1234567_time_to_service', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_time_to_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Volvo XC60 Time to service', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_time_to_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '690', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_automatic_distance-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.volvo_xc60_trip_automatic_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip automatic distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_automatic', + 'unique_id': 'yv1abcdefg1234567_trip_meter_automatic', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_automatic_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Trip automatic distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_automatic_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_fuel_consumption-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.volvo_xc60_trip_manual_average_fuel_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip manual average fuel consumption', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_fuel_consumption', + 'unique_id': 'yv1abcdefg1234567_average_fuel_consumption', + 'unit_of_measurement': 'L/100 km', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_fuel_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC60 Trip manual average fuel consumption', + 'state_class': , + 'unit_of_measurement': 'L/100 km', + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_fuel_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_speed-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.volvo_xc60_trip_manual_average_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual average speed', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_speed', + 'unique_id': 'yv1abcdefg1234567_average_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_average_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'Volvo XC60 Trip manual average speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_average_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_distance-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.volvo_xc60_trip_manual_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trip manual distance', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'trip_meter_manual', + 'unique_id': 'yv1abcdefg1234567_trip_meter_manual', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[xc60_phev_2020][sensor.volvo_xc60_trip_manual_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Volvo XC60 Trip manual distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.volvo_xc60_trip_manual_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '219.7', + }) +# --- # name: test_sensor[xc90_petrol_2019][sensor.volvo_xc90_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index e4cc69470ae..2813c741286 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -15,7 +15,13 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( "full_model", - ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], + [ + "ex30_2024", + "s90_diesel_2018", + "xc40_electric_2024", + "xc60_phev_2020", + "xc90_petrol_2019", + ], ) async def test_sensor( hass: HomeAssistant, @@ -46,3 +52,20 @@ async def test_distance_to_empty_battery( assert await setup_integration() assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250" + + +@pytest.mark.parametrize( + ("full_model", "short_model"), + [("ex30_2024", "ex30"), ("xc60_phev_2020", "xc60")], +) +async def test_skip_invalid_api_fields( + hass: HomeAssistant, + setup_integration: Callable[[], Awaitable[bool]], + short_model: str, +) -> None: + """Test if invalid values are not creating a sensor.""" + + with patch("homeassistant.components.volvo.PLATFORMS", [Platform.SENSOR]): + assert await setup_integration() + + assert not hass.states.get(f"sensor.volvo_{short_model}_charging_power") From 268f0d9e03c580117346645a72b5608351fac172 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sat, 9 Aug 2025 07:47:16 -0400 Subject: [PATCH 1056/1118] Add Tests for Sonos Alarms (#150014) --- tests/components/sonos/conftest.py | 27 ++++++++- tests/components/sonos/test_switch.py | 84 ++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 0cdc17c55a6..7ae3af8f748 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -214,12 +214,26 @@ class MockSoCo(MagicMock): surround_level = 3 music_surround_level = 4 soundbar_audio_input_format = "Dolby 5.1" + factory: SoCoMockFactory | None = None @property def visible_zones(self): """Return visible zones and allow property to be overridden by device classes.""" return {self} + @property + def all_zones(self) -> set[MockSoCo]: + """Return all mock zones if a factory is set and enabled, else just self.""" + return ( + self.factory.mock_all_zones + if self.factory and self.factory.mock_all_zones + else {self} + ) + + def set_factory(self, factory: SoCoMockFactory) -> None: + """Set the factory for this mock.""" + self.factory = factory + class SoCoMockFactory: """Factory for creating SoCo Mocks.""" @@ -243,12 +257,19 @@ class SoCoMockFactory: self.alarm_clock = alarm_clock self.sonos_playlists = sonos_playlists self.sonos_queue = sonos_queue + self.mock_zones: bool = False + + @property + def mock_all_zones(self) -> set[MockSoCo] | None: + """Return a set of all mock zones, or None if not enabled.""" + return set(self.mock_list.values()) if self.mock_zones else None def cache_mock( self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" ) -> MockSoCo: """Put a user created mock into the cache.""" mock_soco.mock_add_spec(SoCo) + mock_soco.set_factory(self) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": mock_soco.uid += f"_{ip_address}" @@ -260,6 +281,11 @@ class SoCoMockFactory: my_speaker_info = self.speaker_info.copy() my_speaker_info["zone_name"] = name my_speaker_info["uid"] = mock_soco.uid + # Generate a different MAC for the non-default speakers. + # otherwise new devices will not be created. + if ip_address != "192.168.42.2": + last_octet = ip_address.split(".")[-1] + my_speaker_info["mac_address"] = f"00-00-00-00-00-{last_octet.zfill(2)}" mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) mock_soco.add_to_queue = Mock(return_value=10) mock_soco.add_uri_to_queue = Mock(return_value=10) @@ -278,7 +304,6 @@ class SoCoMockFactory: mock_soco.alarmClock = self.alarm_clock mock_soco.get_battery_info.return_value = self.battery_info - mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco mock_soco.household_id = "test_household_id" self.mock_list[ip_address] = mock_soco diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index c7df2062b0f..f72abc36470 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.const import ( DATA_SONOS_DISCOVERY_MANAGER, MODEL_SONOS_ARC_ULTRA, @@ -31,10 +32,17 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent, create_rendering_control_event +from .conftest import ( + MockSoCo, + SoCoMockFactory, + SonosMockEvent, + SonosMockService, + create_rendering_control_event, +) from tests.common import async_fire_time_changed @@ -259,3 +267,75 @@ async def test_alarm_create_delete( assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities + + +async def test_alarm_change_device( + hass: HomeAssistant, + async_setup_sonos, + soco: MockSoCo, + alarm_clock: SonosMockService, + alarm_clock_extended: SonosMockService, + alarm_event: SonosMockEvent, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + soco_factory: SoCoMockFactory, +) -> None: + """Test Sonos Alarm being moved to a different speaker. + + This test simulates a scenario where an alarm is created on one speaker + and then moved to another speaker. It checks that the entity is correctly + created on the new speaker and removed from the old one. + """ + + # Create the alarm on the soco_lr speaker + soco_factory.mock_zones = True + soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + alarm_dict = copy(alarm_clock.ListAlarms.return_value) + alarm_dict["CurrentAlarmList"] = alarm_dict["CurrentAlarmList"].replace( + "RINCON_test", f"{soco_lr.uid}" + ) + alarm_dict["CurrentAlarmListVersion"] = f"{soco_lr.uid}:900" + soco_lr.alarmClock.ListAlarms.return_value = alarm_dict + soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["10.10.10.1", "10.10.10.2"], + } + } + }, + ) + await hass.async_block_till_done() + + entity_id = "switch.sonos_alarm_14" + + # Verify the alarm is created on the soco_lr speaker + assert entity_id in entity_registry.entities + entity = entity_registry.async_get(entity_id) + device = device_registry.async_get(entity.device_id) + assert device.name == soco_lr.get_speaker_info()["zone_name"] + + # Simulate the alarm being moved to the soco_br speaker + alarm_update = copy(alarm_clock_extended.ListAlarms.return_value) + alarm_update["CurrentAlarmList"] = alarm_update["CurrentAlarmList"].replace( + "RINCON_test", f"{soco_br.uid}" + ) + alarm_clock.ListAlarms.return_value = alarm_update + + # Update the alarm_list_version so it gets processed. + alarm_event.variables["alarm_list_version"] = f"{soco_br.uid}:1000" + alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( + "alarm_list_version" + ) + + alarm_clock.subscribe.return_value.callback(event=alarm_event) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entity_id in entity_registry.entities + alarm_14 = entity_registry.async_get(entity_id) + device = device_registry.async_get(alarm_14.device_id) + assert device.name == soco_br.get_speaker_info()["zone_name"] From 3e34aa5fb7fc4b3c86000e864d69091e198bcaf9 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 9 Aug 2025 16:26:19 +0300 Subject: [PATCH 1057/1118] Add thinking and native content to chatlog (#149699) --- .../components/conversation/chat_log.py | 34 +++++++++- .../ai_task/snapshots/test_task.ambr | 2 + .../snapshots/test_conversation.ambr | 4 ++ .../conversation/snapshots/test_chat_log.ambr | 68 +++++++++++++++++++ .../components/conversation/test_chat_log.py | 35 ++++++++++ .../snapshots/test_conversation.ambr | 6 ++ .../snapshots/test_conversation.ambr | 10 +++ 7 files changed, 157 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 648a89e47f1..b5348e50b5c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -161,7 +161,9 @@ class AssistantContent: role: Literal["assistant"] = field(init=False, default="assistant") agent_id: str content: str | None = None + thinking_content: str | None = None tool_calls: list[llm.ToolInput] | None = None + native: Any = None @dataclass(frozen=True) @@ -183,7 +185,9 @@ class AssistantContentDeltaDict(TypedDict, total=False): role: Literal["assistant"] content: str | None + thinking_content: str | None tool_calls: list[llm.ToolInput] | None + native: Any @dataclass @@ -306,6 +310,8 @@ class ChatLog: The keys content and tool_calls will be concatenated if they appear multiple times. """ current_content = "" + current_thinking_content = "" + current_native: Any = None current_tool_calls: list[llm.ToolInput] = [] tool_call_tasks: dict[str, asyncio.Task] = {} @@ -316,6 +322,14 @@ class ChatLog: if "role" not in delta: if delta_content := delta.get("content"): current_content += delta_content + if delta_thinking_content := delta.get("thinking_content"): + current_thinking_content += delta_thinking_content + if delta_native := delta.get("native"): + if current_native is not None: + raise RuntimeError( + "Native content already set, cannot overwrite" + ) + current_native = delta_native if delta_tool_calls := delta.get("tool_calls"): if self.llm_api is None: raise ValueError("No LLM API configured") @@ -337,11 +351,18 @@ class ChatLog: raise ValueError(f"Only assistant role expected. Got {delta['role']}") # Yield the previous message if it has content - if current_content or current_tool_calls: + if ( + current_content + or current_thinking_content + or current_tool_calls + or current_native + ): content = AssistantContent( agent_id=agent_id, content=current_content or None, + thinking_content=current_thinking_content or None, tool_calls=current_tool_calls or None, + native=current_native, ) yield content async for tool_result in self.async_add_assistant_content( @@ -352,16 +373,25 @@ class ChatLog: self.delta_listener(self, asdict(tool_result)) current_content = delta.get("content") or "" + current_thinking_content = delta.get("thinking_content") or "" current_tool_calls = delta.get("tool_calls") or [] + current_native = delta.get("native") if self.delta_listener: self.delta_listener(self, delta) # type: ignore[arg-type] - if current_content or current_tool_calls: + if ( + current_content + or current_thinking_content + or current_tool_calls + or current_native + ): content = AssistantContent( agent_id=agent_id, content=current_content or None, + thinking_content=current_thinking_content or None, tool_calls=current_tool_calls or None, + native=current_native, ) yield content async for tool_result in self.async_add_assistant_content( diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 181fc383d64..6986c12f8b7 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -16,7 +16,9 @@ dict({ 'agent_id': 'ai_task.test_task_entity', 'content': 'Mock result', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index d97eaab41e4..9afa6bf5d76 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -19,7 +19,9 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'content': 'Certainly, calling it now!', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'toolu_0123456789AbCdEfGhIjKlM', @@ -40,7 +42,9 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'content': 'I have successfully called the function', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index ff8ebf724cd..a1c53986053 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -3,12 +3,27 @@ list([ ]) # --- +# name: test_add_delta_content_stream[deltas10] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': object( + ), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_add_delta_content_stream[deltas1] list([ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -18,13 +33,17 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), dict({ 'agent_id': 'mock-agent-id', 'content': 'Test 2', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -34,7 +53,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'mock-tool-call-id', @@ -59,7 +80,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'mock-tool-call-id', @@ -84,7 +107,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'mock-tool-call-id', @@ -105,7 +130,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': 'Test 2', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -115,7 +142,9 @@ dict({ 'agent_id': 'mock-agent-id', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'mock-tool-call-id', @@ -149,6 +178,45 @@ }), ]) # --- +# name: test_add_delta_content_stream[deltas7] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': None, + 'role': 'assistant', + 'thinking_content': 'Test Thinking', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas8] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'native': None, + 'role': 'assistant', + 'thinking_content': 'Test Thinking', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas9] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'native': dict({ + 'type': 'test', + 'value': 'Test Native', + }), + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- # name: test_template_error dict({ 'continue_conversation': False, diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 811c045dd70..8fefb41475a 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -517,6 +517,27 @@ async def test_tool_call_exception( ] }, ], + # With thinking content + [ + {"role": "assistant"}, + {"thinking_content": "Test Thinking"}, + ], + # With content and thinking content + [ + {"role": "assistant"}, + {"content": "Test"}, + {"thinking_content": "Test Thinking"}, + ], + # With native content + [ + {"role": "assistant"}, + {"native": {"type": "test", "value": "Test Native"}}, + ], + # With native object content + [ + {"role": "assistant"}, + {"native": object()}, + ], ], ) async def test_add_delta_content_stream( @@ -634,6 +655,20 @@ async def test_add_delta_content_stream_errors( ): pass + # Second native content + with pytest.raises(RuntimeError): + async for _tool_result_content in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream( + [ + {"role": "assistant"}, + {"native": "Test Native"}, + {"native": "Test Native 2"}, + ] + ), + ): + pass + async def test_chat_log_reuse( hass: HomeAssistant, diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index d119c2f6aa5..b60bab02ae7 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -113,7 +113,9 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'content': 'Hello, how can I help you?', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -128,7 +130,9 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'call_call_1', @@ -149,7 +153,9 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'content': 'I have successfully called the function', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 77c52ab97e6..93b86bd4bc1 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -9,7 +9,9 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'call_call_1', @@ -30,7 +32,9 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'call_call_2', @@ -51,7 +55,9 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'content': 'Cool', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) @@ -66,7 +72,9 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'content': None, + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': list([ dict({ 'id': 'call_call_1', @@ -87,7 +95,9 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'content': 'Cool', + 'native': None, 'role': 'assistant', + 'thinking_content': None, 'tool_calls': None, }), ]) From 084cde6ecfb5714d9ae2737bdbd376a43aa62a87 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Aug 2025 21:52:39 +0200 Subject: [PATCH 1058/1118] Add base entity to workday (#150329) --- .../components/workday/binary_sensor.py | 107 +++------------- homeassistant/components/workday/entity.py | 115 ++++++++++++++++++ 2 files changed, 129 insertions(+), 93 deletions(-) create mode 100644 homeassistant/components/workday/entity.py diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index dcda7b901a1..69bdd315609 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,32 +2,24 @@ from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import datetime from typing import Final -from holidays import HolidayBase, __version__ as python_holidays_version +from holidays import HolidayBase import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_NAME -from homeassistant.core import ( - CALLBACK_TYPE, - HomeAssistant, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, async_get_current_platform, ) -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util import dt as dt_util from . import WorkdayConfigEntry -from .const import ALLOWED_DAYS, CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS, DOMAIN +from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS +from .entity import BaseWorkdayEntity SERVICE_CHECK_DATE: Final = "check_date" CHECK_DATE: Final = "check_date" @@ -68,14 +60,10 @@ async def async_setup_entry( ) -class IsWorkdaySensor(BinarySensorEntity): +class IsWorkdaySensor(BaseWorkdayEntity, BinarySensorEntity): """Implementation of a Workday sensor.""" - _attr_has_entity_name = True _attr_name = None - _attr_translation_key = DOMAIN - _attr_should_poll = False - unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -87,87 +75,20 @@ class IsWorkdaySensor(BinarySensorEntity): entry_id: str, ) -> None: """Initialize the Workday sensor.""" - self._obj_holidays = obj_holidays - self._workdays = workdays - self._excludes = excludes - self._days_offset = days_offset + super().__init__( + obj_holidays, + workdays, + excludes, + days_offset, + name, + entry_id, + ) self._attr_extra_state_attributes = { CONF_WORKDAYS: workdays, CONF_EXCLUDES: excludes, CONF_OFFSET: days_offset, } - self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="python-holidays", - model=python_holidays_version, - name=name, - ) - - def is_include(self, day: str, now: date) -> bool: - """Check if given day is in the includes list.""" - if day in self._workdays: - return True - if "holiday" in self._workdays and now in self._obj_holidays: - return True - - return False - - def is_exclude(self, day: str, now: date) -> bool: - """Check if given day is in the excludes list.""" - if day in self._excludes: - return True - if "holiday" in self._excludes and now in self._obj_holidays: - return True - - return False - - def get_next_interval(self, now: datetime) -> datetime: - """Compute next time an update should occur.""" - tomorrow = dt_util.as_local(now) + timedelta(days=1) - return dt_util.start_of_local_day(tomorrow) - - def _update_state_and_setup_listener(self) -> None: - """Update state and setup listener for next interval.""" - now = dt_util.now() - self.update_data(now) - self.unsub = async_track_point_in_utc_time( - self.hass, self.point_in_time_listener, self.get_next_interval(now) - ) - - @callback - def point_in_time_listener(self, time_date: datetime) -> None: - """Get the latest data and update state.""" - self._update_state_and_setup_listener() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Set up first update.""" - self._update_state_and_setup_listener() def update_data(self, now: datetime) -> None: """Get date and look whether it is a holiday.""" self._attr_is_on = self.date_is_workday(now) - - def check_date(self, check_date: date) -> ServiceResponse: - """Service to check if date is workday or not.""" - return {"workday": self.date_is_workday(check_date)} - - def date_is_workday(self, check_date: date) -> bool: - """Check if date is workday.""" - # Default is no workday - is_workday = False - - # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = check_date + timedelta(days=self._days_offset) - day = adjusted_date.isoweekday() - 1 - day_of_week = ALLOWED_DAYS[day] - - if self.is_include(day_of_week, adjusted_date): - is_workday = True - - if self.is_exclude(day_of_week, adjusted_date): - is_workday = False - - return is_workday diff --git a/homeassistant/components/workday/entity.py b/homeassistant/components/workday/entity.py new file mode 100644 index 00000000000..c75a4089ed2 --- /dev/null +++ b/homeassistant/components/workday/entity.py @@ -0,0 +1,115 @@ +"""Base workday entity.""" + +from __future__ import annotations + +from abc import abstractmethod +from datetime import date, datetime, timedelta + +from holidays import HolidayBase, __version__ as python_holidays_version + +from homeassistant.core import CALLBACK_TYPE, ServiceResponse, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import ALLOWED_DAYS, DOMAIN + + +class BaseWorkdayEntity(Entity): + """Implementation of a base Workday entity.""" + + _attr_has_entity_name = True + _attr_translation_key = DOMAIN + _attr_should_poll = False + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, + obj_holidays: HolidayBase, + workdays: list[str], + excludes: list[str], + days_offset: int, + name: str, + entry_id: str, + ) -> None: + """Initialize the Workday entity.""" + self._obj_holidays = obj_holidays + self._workdays = workdays + self._excludes = excludes + self._days_offset = days_offset + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="python-holidays", + model=python_holidays_version, + name=name, + ) + + def is_include(self, day: str, now: date) -> bool: + """Check if given day is in the includes list.""" + if day in self._workdays: + return True + if "holiday" in self._workdays and now in self._obj_holidays: + return True + + return False + + def is_exclude(self, day: str, now: date) -> bool: + """Check if given day is in the excludes list.""" + if day in self._excludes: + return True + if "holiday" in self._excludes and now in self._obj_holidays: + return True + + return False + + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + tomorrow = dt_util.as_local(now) + timedelta(days=1) + return dt_util.start_of_local_day(tomorrow) + + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.now() + self.update_data(now) + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval(now) + ) + + @callback + def point_in_time_listener(self, time_date: datetime) -> None: + """Get the latest data and update state.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Set up first update.""" + self._update_state_and_setup_listener() + + @abstractmethod + def update_data(self, now: datetime) -> None: + """Update data.""" + + def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" + # Default is no workday + is_workday = False + + # Get ISO day of the week (1 = Monday, 7 = Sunday) + adjusted_date = check_date + timedelta(days=self._days_offset) + day = adjusted_date.isoweekday() - 1 + day_of_week = ALLOWED_DAYS[day] + + if self.is_include(day_of_week, adjusted_date): + is_workday = True + + if self.is_exclude(day_of_week, adjusted_date): + is_workday = False + + return is_workday From 2c36a74da5ef4bc82587df843efa7a31da775661 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sat, 9 Aug 2025 19:49:25 -1000 Subject: [PATCH 1059/1118] Also test unique ID in config flow test for APCUPSD (#150362) --- homeassistant/components/apcupsd/quality_scale.yaml | 1 - tests/components/apcupsd/test_config_flow.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index 18fc15cd614..f6fd7e0c2d3 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -13,7 +13,6 @@ rules: Consider looking into making a `mock_setup_entry` fixture that just automatically do this. `test_config_flow_cannot_connect`: Needs to end in CREATE_ENTRY to test that its able to recover. `test_config_flow_duplicate`: this test should be split in 2, one for testing duplicate host/port and one for duplicate serial number. - `test_flow_works`: Should also test unique id. config-flow: done dependency-transparency: done docs-actions: diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index e635b7d6681..6263b5646e5 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -123,6 +123,7 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_STATUS["UPSNAME"] assert result["data"] == CONF_DATA + assert result["result"].unique_id == MOCK_STATUS["SERIALNO"] mock_setup.assert_called_once() From 5262cca8e6772a62516d78d861a19e85e7dbfe62 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Sun, 10 Aug 2025 09:31:26 +0200 Subject: [PATCH 1060/1118] Use "device_id" instead of "slave" in modbus integration (#150200) --- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/modbus.py | 3 ++- tests/components/modbus/test_climate.py | 8 ++++---- tests/components/modbus/test_init.py | 7 ++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index ada56c46f79..dafc604e781 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -97,6 +97,7 @@ CONF_VIRTUAL_COUNT = "virtual_count" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" +DEVICE_ID = "device_id" RTUOVERTCP = "rtuovertcp" SERIAL = "serial" TCP = "tcp" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 6f1a4bfd5b1..5ddde2973ee 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -57,6 +57,7 @@ from .const import ( CONF_PARITY, CONF_STOPBITS, DEFAULT_HUB, + DEVICE_ID, MODBUS_DOMAIN as DOMAIN, PLATFORMS, RTUOVERTCP, @@ -380,7 +381,7 @@ class ModbusHub: ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 54d4c5f6666..38f2aa3337f 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -467,7 +467,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, value=0xAA, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xAA, device_id=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -477,7 +477,7 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) + mock_modbus.write_register.assert_called_with(11, value=0xFF, device_id=10) @pytest.mark.parametrize( @@ -506,7 +506,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_coil.assert_called_with(11, value=1, slave=10) + mock_modbus.write_coil.assert_called_with(11, value=1, device_id=10) await hass.services.async_call( CLIMATE_DOMAIN, @@ -516,7 +516,7 @@ async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: ) await hass.async_block_till_done() - mock_modbus.write_coil.assert_called_with(11, value=0, slave=10) + mock_modbus.write_coil.assert_called_with(11, value=0, device_id=10) @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 4c0a8bd8f6e..be92e12c700 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -63,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWING_MODE_VALUES, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, + DEVICE_ID, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, SERIAL, @@ -867,7 +868,7 @@ async def test_pb_service_write( assert func_name[do_write[FUNC]].called assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) assert func_name[do_write[FUNC]].call_args.kwargs == { - "slave": 17, + DEVICE_ID: 17, value_arg_name[do_write[FUNC]]: data[do_write[DATA]], } @@ -1326,7 +1327,7 @@ async def test_check_default_slave( """Test default slave.""" assert mock_modbus.read_holding_registers.mock_calls first_call = mock_modbus.read_holding_registers.mock_calls[0] - assert first_call.kwargs["slave"] == expected_slave_value + assert first_call.kwargs[DEVICE_ID] == expected_slave_value @pytest.mark.parametrize( @@ -1407,7 +1408,7 @@ async def test_pb_service_write_no_slave( assert func_name[do_write[FUNC]].called assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) assert func_name[do_write[FUNC]].call_args.kwargs == { - "slave": 1, + DEVICE_ID: 1, value_arg_name[do_write[FUNC]]: data[do_write[DATA]], } From dfa060a7e1d723513ce558f737081638888dc635 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 10 Aug 2025 00:38:48 -0700 Subject: [PATCH 1061/1118] Remove Mercury NZ Limited virtual integration (#150316) --- homeassistant/components/mercury_nz/__init__.py | 1 - homeassistant/components/mercury_nz/manifest.json | 6 ------ homeassistant/generated/integrations.json | 5 ----- 3 files changed, 12 deletions(-) delete mode 100644 homeassistant/components/mercury_nz/__init__.py delete mode 100644 homeassistant/components/mercury_nz/manifest.json diff --git a/homeassistant/components/mercury_nz/__init__.py b/homeassistant/components/mercury_nz/__init__.py deleted file mode 100644 index ff22fc5ce4a..00000000000 --- a/homeassistant/components/mercury_nz/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Mercury NZ Limited.""" diff --git a/homeassistant/components/mercury_nz/manifest.json b/homeassistant/components/mercury_nz/manifest.json deleted file mode 100644 index d9d30787067..00000000000 --- a/homeassistant/components/mercury_nz/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "mercury_nz", - "name": "Mercury NZ Limited", - "integration_type": "virtual", - "supported_by": "opower" -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d40f882240b..c656958b3cc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3833,11 +3833,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "mercury_nz": { - "name": "Mercury NZ Limited", - "integration_type": "virtual", - "supported_by": "opower" - }, "message_bird": { "name": "MessageBird", "integration_type": "hub", From d821d2773037b898ced1283cbabc8461e4d06d39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 02:41:25 -0500 Subject: [PATCH 1062/1118] Bump habluetooth to 5.0.1 (#150320) --- homeassistant/components/bluetooth/__init__.py | 14 ++++++++------ homeassistant/components/bluetooth/manager.py | 12 +++++------- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 7abc929fde5..e3428eb9b86 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -388,12 +388,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE scanner = HaScanner(mode, adapter, address) scanner.async_setup() - try: - await scanner.async_start() - except (RuntimeError, ScannerStartError) as err: - raise ConfigEntryNotReady( - f"{adapter_human_name(adapter, address)}: {err}" - ) from err adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] if entry.title == address: @@ -401,8 +395,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, title=adapter_title(adapter, details) ) slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS + # Register the scanner before starting so + # any raw advertisement data can be processed entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots)) await async_update_device(hass, entry, adapter, details) + try: + await scanner.async_start() + except (RuntimeError, ScannerStartError) as err: + raise ConfigEntryNotReady( + f"{adapter_human_name(adapter, address)}: {err}" + ) from err entry.async_on_unload(entry.add_update_listener(async_update_listener)) entry.async_on_unload(scanner.async_stop) return True diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 46c5425c730..5f3cb62c158 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -235,10 +235,9 @@ class HomeAssistantBluetoothManager(BluetoothManager): def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: """Save the scanner history.""" - if isinstance(scanner, BaseHaRemoteScanner): - self.storage.async_set_advertisement_history( - scanner.source, scanner.serialize_discovered_devices() - ) + self.storage.async_set_advertisement_history( + scanner.source, scanner.serialize_discovered_devices() + ) def _async_unregister_scanner( self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE @@ -285,9 +284,8 @@ class HomeAssistantBluetoothManager(BluetoothManager): connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a scanner.""" - if isinstance(scanner, BaseHaRemoteScanner): - if history := self.storage.async_get_advertisement_history(scanner.source): - scanner.restore_discovered_devices(history) + if history := self.storage.async_get_advertisement_history(scanner.source): + scanner.restore_discovered_devices(history) unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ce5d98f8edb..aa0d2076c3f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==4.0.2" + "habluetooth==5.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b79b0ecf6be..ef3843e7da4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==4.0.2 +habluetooth==5.0.1 hass-nabucasa==0.111.2 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index f3ee946e443..a36dafe552d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.2 +habluetooth==5.0.1 # homeassistant.components.cloud hass-nabucasa==0.111.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d26a571437..5eaea0e4258 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.1 # homeassistant.components.bluetooth -habluetooth==4.0.2 +habluetooth==5.0.1 # homeassistant.components.cloud hass-nabucasa==0.111.2 From 1c603f968f6935b69c0c15169bb2fe9a93f60190 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 10 Aug 2025 10:41:55 +0300 Subject: [PATCH 1063/1118] Bump openai to 1.99.5 (#150342) --- homeassistant/components/open_router/manifest.json | 2 +- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 7dd824c2587..4a406e06139 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.99.3", "python-open-router==0.3.1"] + "requirements": ["openai==1.99.5", "python-open-router==0.3.1"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 12ee6278c5d..38ebe205bd3 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.99.3"] + "requirements": ["openai==1.99.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a36dafe552d..0e32d350bbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1601,7 +1601,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.99.3 +openai==1.99.5 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5eaea0e4258..fa1ead8b83b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1369,7 +1369,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.99.3 +openai==1.99.5 # homeassistant.components.openerz openerz-api==0.3.0 From 865b3a66469b02420a98207fabb5065b27006eab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Aug 2025 02:44:15 -0500 Subject: [PATCH 1064/1118] Add raw advertisement data to Bluetooth WebSocket API (#150358) --- .../components/bluetooth/websocket_api.py | 9 ++++++++- tests/components/bluetooth/__init__.py | 2 ++ tests/components/bluetooth/test_websocket_api.py | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index d21b11b050f..9022d98bf06 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -39,7 +39,13 @@ def async_setup(hass: HomeAssistant) -> None: def serialize_service_info( service_info: BluetoothServiceInfoBleak, time_diff: float ) -> dict[str, Any]: - """Serialize a BluetoothServiceInfoBleak object.""" + """Serialize a BluetoothServiceInfoBleak object. + + The raw field is included for: + 1. Debugging - to see the actual advertisement packet + 2. Data freshness - manufacturer_data and service_data are aggregated + across multiple advertisements, raw shows the latest packet only + """ return { "name": service_info.name, "address": service_info.address, @@ -57,6 +63,7 @@ def serialize_service_info( "connectable": service_info.connectable, "time": service_info.time + time_diff, "tx_power": service_info.tx_power, + "raw": service_info.raw.hex() if service_info.raw else None, } diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index d439f46bb71..6951a2ce4cc 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -145,6 +145,7 @@ def inject_advertisement_with_time_and_source_connectable( time: float, source: str, connectable: bool, + raw: bytes | None = None, ) -> None: """Inject an advertisement into the manager from a specific source at a time and connectable status.""" async_get_advertisement_callback(hass)( @@ -161,6 +162,7 @@ def inject_advertisement_with_time_and_source_connectable( connectable=connectable, time=time, tx_power=adv.tx_power, + raw=raw, ) ) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index 2e613932f3c..f12d77913a9 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -22,6 +22,7 @@ from . import ( generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, + inject_advertisement_with_time_and_source_connectable, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -72,6 +73,7 @@ async def test_subscribe_advertisements( "source": HCI0_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, + "raw": None, } ] } @@ -83,8 +85,15 @@ async def test_subscribe_advertisements( service_uuids=[], rssi=-80, ) - inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS + # Inject with raw bytes data + inject_advertisement_with_time_and_source_connectable( + hass, + switchbot_device_signal_100, + switchbot_adv_signal_100, + time.monotonic(), + HCI1_SOURCE_ADDRESS, + True, + raw=b"\x02\x01\x06\x03\x03\x0f\x18", ) async with asyncio.timeout(1): response = await client.receive_json() @@ -101,6 +110,7 @@ async def test_subscribe_advertisements( "source": HCI1_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, + "raw": "02010603030f18", } ] } From d539f37aa44731b73283f948489cb256eccd4131 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Sun, 10 Aug 2025 09:52:17 +0200 Subject: [PATCH 1065/1118] Remove CONF_EXCLUDE_FEEDID constant from the emoncms integration (#150333) --- homeassistant/components/emoncms/const.py | 1 - homeassistant/components/emoncms/sensor.py | 11 ++--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index a3b4629493f..329ec9e3a12 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -2,7 +2,6 @@ import logging -CONF_EXCLUDE_FEEDID = "exclude_feed_id" CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id" CONF_MESSAGE = "message" CONF_SUCCESS = "success" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index c5a25104549..3cb3959d3f2 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -34,13 +34,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import sensor_name -from .const import ( - CONF_EXCLUDE_FEEDID, - CONF_ONLY_INCLUDE_FEEDID, - FEED_ID, - FEED_NAME, - FEED_TAG, -) +from .const import CONF_ONLY_INCLUDE_FEEDID, FEED_ID, FEED_NAME, FEED_TAG from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator SENSORS: dict[str | None, SensorEntityDescription] = { @@ -200,12 +194,11 @@ async def async_setup_entry( ) -> None: """Set up the emoncms sensors.""" name = sensor_name(entry.data[CONF_URL]) - exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID) include_only_feeds = entry.options.get( CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID) ) - if exclude_feeds is None and include_only_feeds is None: + if include_only_feeds is None: return coordinator = entry.runtime_data From b481aaba772960810fc6b2c5bb1d331729d91660 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 10 Aug 2025 11:45:24 +0200 Subject: [PATCH 1066/1118] Fix wrong translation of `unlock_inside_the_door` in `xiaomi_ble` (#150371) thanks --- homeassistant/components/xiaomi_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index ffdd8f29a79..5f64cd1acdc 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -75,7 +75,7 @@ "lock_outside_the_door": "Lock outside the door", "unlock_outside_the_door": "Unlock outside the door", "lock_inside_the_door": "Lock inside the door", - "unlock_inside_the_door": "Unlock outside the door", + "unlock_inside_the_door": "Unlock inside the door", "locked": "Locked", "turn_on_antilock": "Turn on antilock", "release_the_antilock": "Release antilock", From 6d7f8bb7d720ef49c7a9379e4204fad0eb771e21 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 10 Aug 2025 15:14:14 +0200 Subject: [PATCH 1067/1118] Remove unused string scan_interval in upnp component (#150372) --- homeassistant/components/upnp/strings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json index bb414fa95f8..750cffaf1e2 100644 --- a/homeassistant/components/upnp/strings.json +++ b/homeassistant/components/upnp/strings.json @@ -21,7 +21,6 @@ "step": { "init": { "data": { - "scan_interval": "Update interval (seconds, minimal 30)", "force_poll": "Force polling of all data" } } From b1e4513f7d2616cfe86c5235457d76c7bffcde21 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 10 Aug 2025 15:14:40 +0200 Subject: [PATCH 1068/1118] Capitalize "Ice Plus" as feature name in `lg_thinq` (#150370) --- homeassistant/components/lg_thinq/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index d0972a80127..735d1dbf890 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -178,7 +178,7 @@ "no_battery_error": "Robot cleaner's battery is low", "no_dust_bin_error": "Dust bin is not installed", "no_filter_error": "[%key:component::lg_thinq::entity::event::error::state_attributes::event_type::state::filter_clogging_error%]", - "out_of_balance_error": "Out of balance load", + "out_of_balance_error": "Out-of-balance load", "overfill_error": "Overfill error", "part_malfunction_error": "AIE error", "power_code_connection_error": "Power cord connection error", @@ -220,7 +220,7 @@ "error_during_cleaning": "Cleaning stopped due to an error", "error_during_washing": "An error has occurred in the washing machine", "error_has_occurred": "An error has occurred", - "frozen_is_complete": "Ice plus is done", + "frozen_is_complete": "Ice Plus is done", "homeguard_is_stopped": "Home Guard has stopped", "lack_of_water": "There is no water in the water tank", "motion_is_detected": "Photograph is sent as movement is detected during Home Guard", From 0eaea13e8d12a0f14cbd6019613cb9e111e9b8ae Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Aug 2025 16:41:59 +0200 Subject: [PATCH 1069/1118] Update pylint to 3.3.8 + astroid to 3.3.11 (#150327) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 592d4758340..2f680240f6e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.10 +astroid==3.3.11 coverage==7.10.0 freezegun==1.5.2 go2rtc-client==0.2.1 @@ -16,7 +16,7 @@ mock-open==1.4.0 mypy-dev==1.18.0a4 pre-commit==4.2.0 pydantic==2.11.7 -pylint==3.3.7 +pylint==3.3.8 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 pytest-asyncio==1.1.0 From c678bcd4f1f56040decb2eb413f26c8713d16510 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 10 Aug 2025 05:35:48 -1000 Subject: [PATCH 1070/1118] Split test_config_flow_duplicate tests into two separate ones for APCUPSD (#150379) --- .../components/apcupsd/quality_scale.yaml | 1 - tests/components/apcupsd/test_config_flow.py | 68 ++++++++++++------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index f6fd7e0c2d3..6584a9b9461 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -12,7 +12,6 @@ rules: comment: | Consider looking into making a `mock_setup_entry` fixture that just automatically do this. `test_config_flow_cannot_connect`: Needs to end in CREATE_ENTRY to test that its able to recover. - `test_config_flow_duplicate`: this test should be split in 2, one for testing duplicate host/port and one for duplicate serial number. config-flow: done dependency-transparency: done docs-actions: diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 6263b5646e5..768d6c71ff5 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from copy import copy from unittest.mock import patch import pytest @@ -41,9 +40,9 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "cannot_connect" -async def test_config_flow_duplicate(hass: HomeAssistant) -> None: - """Test duplicate config flow setup.""" - # First add an exiting config entry to hass. +async def test_config_flow_duplicate_host_port(hass: HomeAssistant) -> None: + """Test duplicate config flow setup with the same host / port.""" + # First add an existing config entry to hass. mock_entry = MockConfigEntry( version=1, domain=DOMAIN, @@ -60,42 +59,65 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: ) as mock_request_status, _patch_setup(), ): + # Assign the same host and port, which we should reject since the entry already exists. mock_request_status.return_value = MOCK_STATUS - - # Now, create the integration again using the same config data, we should reject - # the creation due same host / port. result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONF_DATA, + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - # Then, we create the integration once again using a different port. However, - # the apcaccess patch is kept to report the same serial number, we should - # reject the creation as well. - another_host = { - CONF_HOST: CONF_DATA[CONF_HOST], - CONF_PORT: CONF_DATA[CONF_PORT] + 1, + # Now we change the host with a different serial number and add it again. This should be successful. + another_host = CONF_DATA | {CONF_HOST: "another_host"} + mock_request_status.return_value = MOCK_STATUS | { + "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" } result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=another_host, ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == another_host + + +async def test_config_flow_duplicate_serial_number(hass: HomeAssistant) -> None: + """Test duplicate config flow setup with different host but the same serial number.""" + # First add an existing config entry to hass. + mock_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + unique_id=MOCK_STATUS["SERIALNO"], + source=SOURCE_USER, + ) + mock_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status, + _patch_setup(), + ): + # Assign the different host and port, but we should still reject the creation since the + # serial number is the same as the existing entry. + mock_request_status.return_value = MOCK_STATUS + another_host = CONF_DATA | {CONF_HOST: "another_host"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=another_host, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" # Now we change the serial number and add it again. This should be successful. - another_device_status = copy(MOCK_STATUS) - another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_request_status.return_value = another_device_status - + mock_request_status.return_value = MOCK_STATUS | { + "SERIALNO": MOCK_STATUS["SERIALNO"] + "ZZZ" + } result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=another_host, + DOMAIN, context={"source": SOURCE_USER}, data=another_host ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == another_host From 1b7cb418eb2de1a2056853b4f63f3462f06c81b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:44:00 +0200 Subject: [PATCH 1071/1118] Add Tuya snapshots tests for cwysj category (pet water fountain) (#150121) --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/cwysj_akln8rb04cav403q.json | 73 +++++++++ .../tuya/snapshots/test_sensor.ambr | 104 +++++++++++++ .../tuya/snapshots/test_switch.ambr | 144 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 249bed68c90..d0a36947f28 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -89,6 +89,11 @@ DEVICE_MOCKS = { Platform.NUMBER, Platform.SENSOR, ], + "cwysj_akln8rb04cav403q": [ + # https://github.com/home-assistant/core/pull/146599 + Platform.SENSOR, + Platform.SWITCH, + ], "cwysj_z3rpyvznfcch99aa": [ # https://github.com/home-assistant/core/pull/146599 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json b/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json new file mode 100644 index 00000000000..0c13dad643a --- /dev/null +++ b/tests/components/tuya/fixtures/cwysj_akln8rb04cav403q.json @@ -0,0 +1,73 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Water Fountain", + "category": "cwysj", + "product_id": "akln8rb04cav403q", + "product_name": "Smart Pet Water Fountain", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-18T11:30:36+00:00", + "create_time": "2025-07-18T11:30:36+00:00", + "update_time": "2025-07-18T11:30:36+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pump_time": { + "type": "Integer", + "value": { + "unit": "day", + "min": 0, + "max": 31, + "scale": 0, + "step": 1 + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "filter_life": { + "type": "Integer", + "value": { + "unit": "day", + "min": 0, + "max": 30, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "pump_time": 7, + "filter_reset": false, + "pump_reset": false, + "filter_life": 14 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f7c304c91e3..56ae0a784b2 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -5092,6 +5092,110 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-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.water_fountain_filter_duration', + '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': 'Filter duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_duration', + 'unique_id': 'tuya.q304vac40br8nlkajsywcfilter_life', + 'unit_of_measurement': 'day', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Filter duration', + 'state_class': , + 'unit_of_measurement': 'day', + }), + 'context': , + 'entity_id': 'sensor.water_fountain_filter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_water_pump_duration-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.water_fountain_water_pump_duration', + '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': 'Water pump duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_time', + 'unique_id': 'tuya.q304vac40br8nlkajsywcpump_time', + 'unit_of_measurement': 'day', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.water_fountain_water_pump_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Water pump duration', + 'state_class': , + 'unit_of_measurement': 'day', + }), + 'context': , + 'entity_id': 'sensor.water_fountain_water_pump_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 67f5316ce0e..5c7d3f8ebc7 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -3435,6 +3435,150 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-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': , + 'entity_id': 'switch.water_fountain_filter_reset', + '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': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.q304vac40br8nlkajsywcfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Filter reset', + }), + 'context': , + 'entity_id': 'switch.water_fountain_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_power-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.water_fountain_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.q304vac40br8nlkajsywcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Power', + }), + 'context': , + 'entity_id': 'switch.water_fountain_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_water_pump_reset-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': , + 'entity_id': 'switch.water_fountain_water_pump_reset', + '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': 'Water pump reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pump_reset', + 'unique_id': 'tuya.q304vac40br8nlkajsywcpump_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.water_fountain_water_pump_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Fountain Water pump reset', + }), + 'context': , + 'entity_id': 'switch.water_fountain_water_pump_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From efe519faad2e5ebe49ec5bd709652f5de38b1fdd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:44:26 +0200 Subject: [PATCH 1072/1118] Add mute support to Tuya wg2 category (gateway) (#150122) --- .../components/tuya/binary_sensor.py | 4 +- homeassistant/components/tuya/switch.py | 9 ++ tests/components/tuya/__init__.py | 13 +++ .../tuya/fixtures/wg2_haclbl0qkqlf2qds.json | 77 +++++++++++++++ .../tuya/fixtures/wg2_setmxeqgs63xwopm.json | 68 +++++++++++++ .../tuya/fixtures/wg2_v7owd9tzcaninc36.json | 21 ++++ .../tuya/snapshots/test_binary_sensor.ambr | 98 +++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++ 8 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json create mode 100644 tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json create mode 100644 tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index fd3f0cfcb7e..defbddb381d 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -314,8 +314,8 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # Zigbee gateway - # Undocumented + # Gateway control + # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok "wg2": ( TuyaBinarySensorEntityDescription( key=DPCode.MASTER_STATE, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index ecd7d9f4f44..6be878edbca 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -785,6 +785,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Gateway control + # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok + "wg2": ( + SwitchEntityDescription( + key=DPCode.MUFFLING, + translation_key="mute", + entity_category=EntityCategory.CONFIG, + ), + ), # Thermostat # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 "wk": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index d0a36947f28..dc518d40c18 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -433,10 +433,23 @@ DEVICE_MOCKS = { "wfcon_b25mh8sxawsgndck": [ # https://github.com/home-assistant/core/issues/149704 ], + "wg2_haclbl0qkqlf2qds": [ + # https://github.com/orgs/home-assistant/discussions/517 + Platform.BINARY_SENSOR, + Platform.SWITCH, + ], "wg2_nwxr8qcu4seltoro": [ # https://github.com/orgs/home-assistant/discussions/430 Platform.BINARY_SENSOR, ], + "wg2_setmxeqgs63xwopm": [ + # https://github.com/orgs/home-assistant/discussions/539 + Platform.BINARY_SENSOR, + ], + "wg2_v7owd9tzcaninc36": [ + # https://github.com/orgs/home-assistant/discussions/539 + # SDK information is empty + ], "wk_6kijc7nd": [ # https://github.com/home-assistant/core/issues/136513 Platform.CLIMATE, diff --git a/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json b/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json new file mode 100644 index 00000000000..7b5a5e2dece --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_haclbl0qkqlf2qds.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Home Gateway", + "category": "wg2", + "product_id": "haclbl0qkqlf2qds", + "product_name": "Multi-mode Gateway", + "online": false, + "sub": true, + "time_zone": "+03:00", + "active_time": "2025-08-03T10:30:30+00:00", + "create_time": "2025-08-03T10:30:30+00:00", + "update_time": "2025-08-03T10:30:30+00:00", + "function": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_alarm_sound": false, + "muffling": false, + "master_state": "normal", + "factory_reset": false, + "alarm_active": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json b/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json new file mode 100644 index 00000000000..54cc51114c5 --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_setmxeqgs63xwopm.json @@ -0,0 +1,68 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gateway", + "category": "wg2", + "product_id": "setmxeqgs63xwopm", + "product_name": "", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2021-11-26T13:33:58+00:00", + "create_time": "2021-11-26T13:33:58+00:00", + "update_time": "2021-11-26T13:33:58+00:00", + "function": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "alarm_active": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "switch_alarm_sound": false, + "master_state": "normal", + "factory_reset": false, + "alarm_active": "" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json b/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json new file mode 100644 index 00000000000..dd8bbbbec2b --- /dev/null +++ b/tests/components/tuya/fixtures/wg2_v7owd9tzcaninc36.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gateway2", + "category": "wg2", + "product_id": "v7owd9tzcaninc36", + "product_name": "Gateway", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-07-16T09:32:51+00:00", + "create_time": "2023-07-16T09:32:51+00:00", + "update_time": "2023-07-16T09:32:51+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index d26bcac6d6d..e464db94eaf 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -391,6 +391,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.gateway_problem-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.gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mpowx36sgqexmtes2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.home_gateway_problem-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.home_gateway_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.sdq2flqkq0lblcah2gwmaster_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.home_gateway_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Home Gateway Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.home_gateway_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.human_presence_office_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 5c7d3f8ebc7..9957744b6a5 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1837,6 +1837,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.home_gateway_mute-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': , + 'entity_id': 'switch.home_gateway_mute', + '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': 'Mute', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mute', + 'unique_id': 'tuya.sdq2flqkq0lblcah2gwmuffling', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.home_gateway_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Gateway Mute', + }), + 'context': , + 'entity_id': 'switch.home_gateway_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 712ddc03c8d281b11f21f0491821c101dbabd864 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:44:55 +0200 Subject: [PATCH 1073/1118] Add Tuya snapshots tests for kj category (air purifier) (#150171) --- tests/components/tuya/__init__.py | 7 ++ .../tuya/fixtures/kj_fsxtzzhujkrak2oy.json | 104 ++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 61 ++++++++++ .../tuya/snapshots/test_select.ambr | 65 +++++++++++ .../tuya/snapshots/test_sensor.ambr | 97 ++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 96 ++++++++++++++++ 6 files changed, 430 insertions(+) create mode 100644 tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index dc518d40c18..163757cf285 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -271,6 +271,13 @@ DEVICE_MOCKS = { Platform.FAN, Platform.SWITCH, ], + "kj_fsxtzzhujkrak2oy": [ + # https://github.com/orgs/home-assistant/discussions/439 + Platform.FAN, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], "kj_yrzylxax1qspdgpp": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, diff --git a/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json b/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json new file mode 100644 index 00000000000..1fe8ead167f --- /dev/null +++ b/tests/components/tuya/fixtures/kj_fsxtzzhujkrak2oy.json @@ -0,0 +1,104 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Kalado Air Purifier", + "category": "kj", + "product_id": "fsxtzzhujkrak2oy", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-06-23T16:03:21+00:00", + "create_time": "2024-06-23T16:03:21+00:00", + "update_time": "2024-06-23T16:03:21+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto", "sleep"] + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["manual", "auto", "sleep"] + } + }, + "filter": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + }, + "air_quality": { + "type": "Enum", + "value": { + "range": ["great", "good", "medium", "severe"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["e1", "e2"] + } + } + }, + "status": { + "switch": false, + "pm25": 3, + "mode": "auto", + "filter": 42, + "filter_reset": false, + "countdown_set": "cancel", + "air_quality": "great", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 4efda28459e..f3088b51d45 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -381,6 +381,67 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fan.kalado_air_purifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'manual', + 'auto', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.kalado_air_purifier', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.yo2karkjuhzztxsfjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.kalado_air_purifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier', + 'preset_mode': 'auto', + 'preset_modes': list([ + 'manual', + 'auto', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.kalado_air_purifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[fan.tower_fan_ca_407g_smart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index df0a5b38a99..9acc761f805 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1122,6 +1122,71 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.kalado_air_purifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kalado_air_purifier_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.kalado_air_purifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'context': , + 'entity_id': 'select.kalado_air_purifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 56ae0a784b2..bdccfb56a5c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2113,6 +2113,103 @@ 'state': '121.7', }) # --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-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.kalado_air_purifier_air_quality', + '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': 'Air quality', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkair_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Air quality', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'great', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_filter_utilization-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.kalado_air_purifier_filter_utilization', + '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': 'Filter utilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_utilization', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkfilter', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_filter_utilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Filter utilization', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kalado_air_purifier_filter_utilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 9957744b6a5..30c54d25bbc 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2080,6 +2080,102 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_filter_cartridge_reset-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': , + 'entity_id': 'switch.kalado_air_purifier_filter_cartridge_reset', + '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': 'Filter cartridge reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cartridge_reset', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_filter_cartridge_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Filter cartridge reset', + }), + 'context': , + 'entity_id': 'switch.kalado_air_purifier_filter_cartridge_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_power-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.kalado_air_purifier_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.yo2karkjuhzztxsfjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kalado_air_purifier_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kalado Air Purifier Power', + }), + 'context': , + 'entity_id': 'switch.kalado_air_purifier_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2b158fe690cc42d2078957730a882acb4df92f2a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:45:40 +0200 Subject: [PATCH 1074/1118] Add Tuya snapshots tests for kt category (air conditioner) (#150256) --- tests/components/tuya/__init__.py | 8 + .../tuya/fixtures/kt_ibmmirhhq62mmf1g.json | 110 +++++++++++++ .../tuya/fixtures/kt_vdadlnmsorlhw4td.json | 78 +++++++++ .../tuya/snapshots/test_climate.ambr | 150 ++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json create mode 100644 tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 163757cf285..0cf8022eed3 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -294,6 +294,14 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/pull/148646 Platform.CLIMATE, ], + "kt_ibmmirhhq62mmf1g": [ + # https://github.com/home-assistant/core/pull/150077 + Platform.CLIMATE, + ], + "kt_vdadlnmsorlhw4td": [ + # https://github.com/home-assistant/core/pull/149635 + Platform.CLIMATE, + ], "ldcg_9kbbfeho": [ # https://github.com/orgs/home-assistant/discussions/482 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json b/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json new file mode 100644 index 00000000000..e7657a7b0e9 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_ibmmirhhq62mmf1g.json @@ -0,0 +1,110 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Master Bedroom AC", + "category": "kt", + "product_id": "ibmmirhhq62mmf1g", + "product_name": "T platform model-USB ", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2025-07-16T14:12:18+00:00", + "create_time": "2025-07-16T14:12:18+00:00", + "update_time": "2025-07-16T14:12:18+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 160, + "max": 880, + "scale": 1, + "step": 5 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot", "wet", "wind", "auto"] + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 61, + "max": 88, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 160, + "max": 880, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot", "wet", "wind", "auto"] + } + }, + "humidity_current": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_set_f": { + "type": "Integer", + "value": { + "unit": "\u2109", + "min": 61, + "max": 88, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "temp_set": 750, + "temp_current": 26, + "mode": "cold", + "humidity_current": 0, + "temp_set_f": 61 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json b/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json new file mode 100644 index 00000000000..0f07e4a13e7 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_vdadlnmsorlhw4td.json @@ -0,0 +1,78 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sove", + "category": "kt", + "product_id": "vdadlnmsorlhw4td", + "product_name": "YFA-05C", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T11:44:00+00:00", + "create_time": "2025-07-07T11:44:00+00:00", + "update_time": "2025-07-07T11:44:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -7, + "max": 110, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 16, + "temp_current": 24, + "windspeed": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 8353db8a8e1..270eeef1577 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -230,6 +230,81 @@ 'state': 'heat_cool', }) # --- +# name: test_platform_setup_and_discovery[climate.master_bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 88.0, + 'min_temp': 16.0, + '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.master_bedroom_ac', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.g1fmm26qhhrimmbitk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.master_bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 0, + 'current_temperature': 26.0, + 'friendly_name': 'Master Bedroom AC', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 88.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 75.0, + }), + 'context': , + 'entity_id': 'climate.master_bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_platform_setup_and_discovery[climate.smart_thermostats-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -298,6 +373,81 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[climate.sove-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.sove', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.dt4whlrosmnldadvtk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[climate.sove-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.0, + 'fan_mode': 2, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Sove', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 16.0, + }), + 'context': , + 'entity_id': 'climate.sove', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[climate.term_prizemi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6b83effc5fa797c00134c1253e281f15a9b8ca3b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 17:58:51 +0200 Subject: [PATCH 1075/1118] Simplify DEVICE_MOCKS in Tuya (#150381) --- tests/components/tuya/__init__.py | 634 ++++++------------------------ tests/components/tuya/conftest.py | 2 +- 2 files changed, 111 insertions(+), 525 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 0cf8022eed3..dfce03e80ea 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -8,534 +8,120 @@ from unittest.mock import patch from tuya_sharing import CustomerDevice from homeassistant.components.tuya import DeviceListener, ManagerCompat -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -DEVICE_MOCKS = { - "cl_3r8gc33pnqsxfe1g": [ - # https://github.com/tuya/tuya-home-assistant/issues/754 - Platform.COVER, - Platform.SENSOR, - Platform.SWITCH, - ], - "cl_cpbo62rn": [ - # https://github.com/orgs/home-assistant/discussions/539 - Platform.COVER, - Platform.SELECT, - ], - "cl_ebt12ypvexnixvtf": [ - # https://github.com/tuya/tuya-home-assistant/issues/754 - Platform.COVER, - ], - "cl_qqdxfdht": [ - # https://github.com/orgs/home-assistant/discussions/539 - Platform.COVER, - ], - "cl_zah67ekd": [ - # https://github.com/home-assistant/core/issues/71242 - Platform.COVER, - Platform.SELECT, - ], - "clkg_nhyj64w2": [ - # https://github.com/home-assistant/core/issues/136055 - Platform.COVER, - Platform.LIGHT, - ], - "co2bj_yrr3eiyiacm31ski": [ - # https://github.com/home-assistant/core/issues/133173 - Platform.BINARY_SENSOR, - Platform.NUMBER, - Platform.SELECT, - Platform.SENSOR, - Platform.SIREN, - ], - "cs_ka2wfrdoogpvgzfi": [ - # https://github.com/home-assistant/core/issues/119865 - Platform.BINARY_SENSOR, - Platform.FAN, - Platform.HUMIDIFIER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "cs_qhxmvae667uap4zh": [ - # https://github.com/home-assistant/core/issues/141278 - Platform.FAN, - Platform.HUMIDIFIER, - ], - "cs_vmxuxszzjwp5smli": [ - # https://github.com/home-assistant/core/issues/119865 - Platform.FAN, - Platform.HUMIDIFIER, - ], - "cs_zibqa9dutqyaxym2": [ - Platform.BINARY_SENSOR, - Platform.FAN, - Platform.HUMIDIFIER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "cwjwq_agwu93lr": [ - # https://github.com/orgs/home-assistant/discussions/79 - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "cwwsq_wfkzyy0evslzsmoi": [ - # https://github.com/home-assistant/core/issues/144745 - Platform.NUMBER, - Platform.SENSOR, - ], - "cwysj_akln8rb04cav403q": [ - # https://github.com/home-assistant/core/pull/146599 - Platform.SENSOR, - Platform.SWITCH, - ], - "cwysj_z3rpyvznfcch99aa": [ - # https://github.com/home-assistant/core/pull/146599 - Platform.SENSOR, - Platform.SWITCH, - ], - "cz_0g1fmqh6d5io7lcn": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SWITCH, - ], - "cz_2jxesipczks0kdct": [ - # https://github.com/home-assistant/core/issues/147149 - Platform.SENSOR, - Platform.SWITCH, - ], - "cz_cuhokdii7ojyw8k2": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SWITCH, - ], - "cz_dntgh2ngvshfxpsz": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SWITCH, - ], - "cz_hj0a5c7ckzzexu8l": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SENSOR, - Platform.SWITCH, - ], - "cz_t0a4hwsf8anfsadp": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SELECT, - Platform.SWITCH, - ], - "dc_l3bpgg8ibsagon4x": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_8szt7whdvwpmxglk": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_8y0aquaa8v6tho8w": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_baf9tt9lb8t5uc7z": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_d4g0fbsoaal841o6": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_djnozmdyqyriow8z": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_ekwolitfjhxn55js": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_fuupmcr2mb1odkja": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_hp6orhaqm6as3jnv": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_hpc8ddyfv85haxa7": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_iayz2jmtlipjnxj7": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_idnfq7xbx8qewyoa": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_ilddqqih3tucdk68": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_j1bgp31cffutizub": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_lmnt3uyltk1xffrt": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_mki13ie507rlry4r": [ - # https://github.com/home-assistant/core/pull/126242 - Platform.LIGHT, - ], - "dj_nbumqpv8vz61enji": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_nlxvjzy1hoeiqsg6": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_oe0cpnjg": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_riwp3k79": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_tmsloaroqavbucgn": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_ufq2xwuzd4nb0qdr": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_vqwcnabamzrc2kab": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_xokdfs6kh5ednakk": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_zakhnlpdiu0ycdxn": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_zav1pa32pyxray78": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dj_zputiamzanuk6yky": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - ], - "dlq_0tnvg2xaisqdadcf": [ - # https://github.com/home-assistant/core/issues/102769 - Platform.SENSOR, - Platform.SWITCH, - ], - "dlq_kxdr6su0c55p7bbo": [ - # https://github.com/home-assistant/core/issues/143499 - Platform.SENSOR, - ], - "fs_g0ewlb1vmwqljzji": [ - # https://github.com/home-assistant/core/issues/141231 - Platform.FAN, - Platform.LIGHT, - Platform.SELECT, - ], - "fs_ibytpo6fpnugft1c": [ - # https://github.com/home-assistant/core/issues/135541 - Platform.FAN, - ], - "gyd_lgekqfxdabipm3tn": [ - # https://github.com/home-assistant/core/issues/133173 - Platform.LIGHT, - ], - "hps_2aaelwxk": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.BINARY_SENSOR, - Platform.NUMBER, - ], - "kg_gbm9ata1zrzaez4a": [ - # https://github.com/home-assistant/core/issues/148347 - Platform.SWITCH, - ], - "kj_CAjWAxBUZt7QZHfz": [ - # https://github.com/home-assistant/core/issues/146023 - Platform.FAN, - Platform.SWITCH, - ], - "kj_fsxtzzhujkrak2oy": [ - # https://github.com/orgs/home-assistant/discussions/439 - Platform.FAN, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "kj_yrzylxax1qspdgpp": [ - # https://github.com/orgs/home-assistant/discussions/61 - Platform.FAN, - Platform.SELECT, - Platform.SWITCH, - ], - "ks_j9fa8ahzac8uvlfl": [ - # https://github.com/orgs/home-assistant/discussions/329 - Platform.FAN, - Platform.LIGHT, - Platform.SWITCH, - ], - "kt_5wnlzekkstwcdsvm": [ - # https://github.com/home-assistant/core/pull/148646 - Platform.CLIMATE, - ], - "kt_ibmmirhhq62mmf1g": [ - # https://github.com/home-assistant/core/pull/150077 - Platform.CLIMATE, - ], - "kt_vdadlnmsorlhw4td": [ - # https://github.com/home-assistant/core/pull/149635 - Platform.CLIMATE, - ], - "ldcg_9kbbfeho": [ - # https://github.com/orgs/home-assistant/discussions/482 - Platform.SENSOR, - ], - "mal_gyitctrjj1kefxp2": [ - # Alarm Host support - Platform.ALARM_CONTROL_PANEL, - Platform.NUMBER, - Platform.SWITCH, - ], - "mcs_7jIGJAymiH8OsFFb": [ - # https://github.com/home-assistant/core/issues/108301 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "mzj_qavcakohisj5adyh": [ - # https://github.com/home-assistant/core/issues/141278 - Platform.NUMBER, - Platform.SENSOR, - Platform.SWITCH, - ], - "pc_t2afic7i3v1bwhfp": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SWITCH, - ], - "pc_trjopo1vdlt9q1tg": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.SWITCH, - ], - "pir_3amxzozho9xp4mkh": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "pir_fcdjzz3s": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "pir_wqz93nrdomectyoz": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "qccdz_7bvgooyjhiua1yyq": [ - # https://github.com/home-assistant/core/issues/136207 - Platform.SWITCH, - ], - "qxj_fsea1lat3vuktbt6": [ - # https://github.com/orgs/home-assistant/discussions/318 - Platform.SENSOR, - ], - "qxj_is2indt9nlth6esa": [ - # https://github.com/home-assistant/core/issues/136472 - Platform.SENSOR, - ], - "rqbj_4iqe2hsfyd86kwwc": [ - # https://github.com/orgs/home-assistant/discussions/100 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "sd_lr33znaodtyarrrz": [ - # https://github.com/home-assistant/core/issues/141278 - Platform.BUTTON, - Platform.NUMBER, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - Platform.VACUUM, - ], - "sfkzq_o6dagifntoafakst": [ - # https://github.com/home-assistant/core/issues/148116 - Platform.SWITCH, - ], - "sgbj_ulv4nnue7gqp0rjk": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.NUMBER, - Platform.SELECT, - Platform.SIREN, - ], - "sj_tgvtvdoc": [ - # https://github.com/orgs/home-assistant/discussions/482 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "sp_drezasavompxpcgm": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.CAMERA, - Platform.LIGHT, - Platform.SELECT, - Platform.SWITCH, - ], - "sp_rjKXWRohlvOTyLBu": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.CAMERA, - Platform.LIGHT, - Platform.SELECT, - Platform.SWITCH, - ], - "sp_sdd5f5f2dl5wydjf": [ - # https://github.com/home-assistant/core/issues/144087 - Platform.CAMERA, - Platform.NUMBER, - Platform.SENSOR, - Platform.SELECT, - Platform.SIREN, - Platform.SWITCH, - ], - "tdq_1aegphq4yfd50e6b": [ - # https://github.com/home-assistant/core/issues/143209 - Platform.SELECT, - Platform.SWITCH, - ], - "tdq_9htyiowaf5rtdhrv": [ - # https://github.com/home-assistant/core/issues/143209 - Platform.SELECT, - Platform.SWITCH, - ], - "tdq_cq1p0nt0a4rixnex": [ - # https://github.com/home-assistant/core/issues/146845 - Platform.SELECT, - Platform.SWITCH, - ], - "tdq_nockvv2k39vbrxxk": [ - # https://github.com/home-assistant/core/issues/145849 - Platform.SWITCH, - ], - "tdq_pu8uhxhwcp3tgoz7": [ - # https://github.com/home-assistant/core/issues/141278 - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ], - "tdq_uoa3mayicscacseb": [ - # https://github.com/home-assistant/core/issues/128911 - # SDK information is empty - ], - "tyndj_pyakuuoc": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.LIGHT, - Platform.SENSOR, - Platform.SWITCH, - ], - "wfcon_b25mh8sxawsgndck": [ - # https://github.com/home-assistant/core/issues/149704 - ], - "wg2_haclbl0qkqlf2qds": [ - # https://github.com/orgs/home-assistant/discussions/517 - Platform.BINARY_SENSOR, - Platform.SWITCH, - ], - "wg2_nwxr8qcu4seltoro": [ - # https://github.com/orgs/home-assistant/discussions/430 - Platform.BINARY_SENSOR, - ], - "wg2_setmxeqgs63xwopm": [ - # https://github.com/orgs/home-assistant/discussions/539 - Platform.BINARY_SENSOR, - ], - "wg2_v7owd9tzcaninc36": [ - # https://github.com/orgs/home-assistant/discussions/539 - # SDK information is empty - ], - "wk_6kijc7nd": [ - # https://github.com/home-assistant/core/issues/136513 - Platform.CLIMATE, - Platform.NUMBER, - Platform.SWITCH, - ], - "wk_aqoouq7x": [ - # https://github.com/home-assistant/core/issues/146263 - Platform.CLIMATE, - Platform.SWITCH, - ], - "wk_fi6dne5tu4t1nm6j": [ - # https://github.com/orgs/home-assistant/discussions/243 - Platform.CLIMATE, - Platform.NUMBER, - Platform.SENSOR, - Platform.SWITCH, - ], - "wk_gogb05wrtredz3bs": [ - # https://github.com/home-assistant/core/issues/136337 - Platform.CLIMATE, - Platform.NUMBER, - Platform.SWITCH, - ], - "wk_y5obtqhuztqsf2mj": [ - # https://github.com/home-assistant/core/issues/139735 - Platform.CLIMATE, - Platform.SWITCH, - ], - "wsdcg_g2y6z3p3ja2qhyav": [ - # https://github.com/home-assistant/core/issues/102769 - Platform.SENSOR, - ], - "wxkg_l8yaz4um5b3pwyvf": [ - # https://github.com/home-assistant/core/issues/93975 - Platform.EVENT, - Platform.SENSOR, - ], - "ydkt_jevroj5aguwdbs2e": [ - # https://github.com/orgs/home-assistant/discussions/288 - # unsupported device - no platforms - ], - "ywbj_gf9dejhmzffgdyfj": [ - # https://github.com/home-assistant/core/issues/149704 - Platform.BINARY_SENSOR, - Platform.SENSOR, - ], - "ywcgq_h8lvyoahr6s6aybf": [ - # https://github.com/home-assistant/core/issues/145932 - Platform.NUMBER, - Platform.SENSOR, - ], - "ywcgq_wtzwyhkev3b4ubns": [ - # https://github.com/home-assistant/core/issues/103818 - Platform.NUMBER, - Platform.SENSOR, - ], - "zndb_4ggkyflayu1h1ho9": [ - # https://github.com/home-assistant/core/pull/149317 - Platform.SENSOR, - Platform.SWITCH, - ], - "zndb_ze8faryrxr0glqnn": [ - # https://github.com/home-assistant/core/issues/138372 - Platform.SENSOR, - ], - "zwjcy_myd45weu": [ - # https://github.com/orgs/home-assistant/discussions/482 - Platform.SENSOR, - ], -} +DEVICE_MOCKS = [ + "cl_3r8gc33pnqsxfe1g", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cl_cpbo62rn", # https://github.com/orgs/home-assistant/discussions/539 + "cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539 + "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 + "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 + "co2bj_yrr3eiyiacm31ski", # https://github.com/home-assistant/core/issues/133173 + "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 + "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 + "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 + "cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098 + "cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79 + "cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599 + "cwwsq_wfkzyy0evslzsmoi", # https://github.com/home-assistant/core/issues/144745 + "cwysj_z3rpyvznfcch99aa", # https://github.com/home-assistant/core/pull/146599 + "cz_0g1fmqh6d5io7lcn", # https://github.com/home-assistant/core/issues/149704 + "cz_2jxesipczks0kdct", # https://github.com/home-assistant/core/issues/147149 + "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 + "cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704 + "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 + "cz_t0a4hwsf8anfsadp", # https://github.com/home-assistant/core/issues/149704 + "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 + "dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704 + "dj_8y0aquaa8v6tho8w", # https://github.com/home-assistant/core/issues/149704 + "dj_baf9tt9lb8t5uc7z", # https://github.com/home-assistant/core/issues/149704 + "dj_d4g0fbsoaal841o6", # https://github.com/home-assistant/core/issues/149704 + "dj_djnozmdyqyriow8z", # https://github.com/home-assistant/core/issues/149704 + "dj_ekwolitfjhxn55js", # https://github.com/home-assistant/core/issues/149704 + "dj_fuupmcr2mb1odkja", # https://github.com/home-assistant/core/issues/149704 + "dj_hp6orhaqm6as3jnv", # https://github.com/home-assistant/core/issues/149704 + "dj_hpc8ddyfv85haxa7", # https://github.com/home-assistant/core/issues/149704 + "dj_iayz2jmtlipjnxj7", # https://github.com/home-assistant/core/issues/149704 + "dj_idnfq7xbx8qewyoa", # https://github.com/home-assistant/core/issues/149704 + "dj_ilddqqih3tucdk68", # https://github.com/home-assistant/core/issues/149704 + "dj_j1bgp31cffutizub", # https://github.com/home-assistant/core/issues/149704 + "dj_lmnt3uyltk1xffrt", # https://github.com/home-assistant/core/issues/149704 + "dj_mki13ie507rlry4r", # https://github.com/home-assistant/core/pull/126242 + "dj_nbumqpv8vz61enji", # https://github.com/home-assistant/core/issues/149704 + "dj_nlxvjzy1hoeiqsg6", # https://github.com/home-assistant/core/issues/149704 + "dj_oe0cpnjg", # https://github.com/home-assistant/core/issues/149704 + "dj_riwp3k79", # https://github.com/home-assistant/core/issues/149704 + "dj_tmsloaroqavbucgn", # https://github.com/home-assistant/core/issues/149704 + "dj_ufq2xwuzd4nb0qdr", # https://github.com/home-assistant/core/issues/149704 + "dj_vqwcnabamzrc2kab", # https://github.com/home-assistant/core/issues/149704 + "dj_xokdfs6kh5ednakk", # https://github.com/home-assistant/core/issues/149704 + "dj_zakhnlpdiu0ycdxn", # https://github.com/home-assistant/core/issues/149704 + "dj_zav1pa32pyxray78", # https://github.com/home-assistant/core/issues/149704 + "dj_zputiamzanuk6yky", # https://github.com/home-assistant/core/issues/149704 + "dlq_0tnvg2xaisqdadcf", # https://github.com/home-assistant/core/issues/102769 + "dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499 + "fs_g0ewlb1vmwqljzji", # https://github.com/home-assistant/core/issues/141231 + "fs_ibytpo6fpnugft1c", # https://github.com/home-assistant/core/issues/135541 + "gyd_lgekqfxdabipm3tn", # https://github.com/home-assistant/core/issues/133173 + "hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704 + "kg_gbm9ata1zrzaez4a", # https://github.com/home-assistant/core/issues/148347 + "kj_CAjWAxBUZt7QZHfz", # https://github.com/home-assistant/core/issues/146023 + "kj_fsxtzzhujkrak2oy", # https://github.com/orgs/home-assistant/discussions/439 + "kj_yrzylxax1qspdgpp", # https://github.com/orgs/home-assistant/discussions/61 + "ks_j9fa8ahzac8uvlfl", # https://github.com/orgs/home-assistant/discussions/329 + "kt_5wnlzekkstwcdsvm", # https://github.com/home-assistant/core/pull/148646 + "kt_ibmmirhhq62mmf1g", # https://github.com/home-assistant/core/pull/150077 + "kt_vdadlnmsorlhw4td", # https://github.com/home-assistant/core/pull/149635 + "ldcg_9kbbfeho", # https://github.com/orgs/home-assistant/discussions/482 + "mal_gyitctrjj1kefxp2", # Alarm Host support + "mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301 + "mzj_qavcakohisj5adyh", # https://github.com/home-assistant/core/issues/141278 + "pc_t2afic7i3v1bwhfp", # https://github.com/home-assistant/core/issues/149704 + "pc_trjopo1vdlt9q1tg", # https://github.com/home-assistant/core/issues/149704 + "pir_3amxzozho9xp4mkh", # https://github.com/home-assistant/core/issues/149704 + "pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704 + "pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704 + "qccdz_7bvgooyjhiua1yyq", # https://github.com/home-assistant/core/issues/136207 + "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 + "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 + "rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100 + "sd_lr33znaodtyarrrz", # https://github.com/home-assistant/core/issues/141278 + "sfkzq_o6dagifntoafakst", # https://github.com/home-assistant/core/issues/148116 + "sgbj_ulv4nnue7gqp0rjk", # https://github.com/home-assistant/core/issues/149704 + "sj_tgvtvdoc", # https://github.com/orgs/home-assistant/discussions/482 + "sp_drezasavompxpcgm", # https://github.com/home-assistant/core/issues/149704 + "sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704 + "sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087 + "tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209 + "tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209 + "tdq_cq1p0nt0a4rixnex", # https://github.com/home-assistant/core/issues/146845 + "tdq_nockvv2k39vbrxxk", # https://github.com/home-assistant/core/issues/145849 + "tdq_pu8uhxhwcp3tgoz7", # https://github.com/home-assistant/core/issues/141278 + "tdq_uoa3mayicscacseb", # https://github.com/home-assistant/core/issues/128911 + "tyndj_pyakuuoc", # https://github.com/home-assistant/core/issues/149704 + "wfcon_b25mh8sxawsgndck", # https://github.com/home-assistant/core/issues/149704 + "wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517 + "wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430 + "wg2_setmxeqgs63xwopm", # https://github.com/orgs/home-assistant/discussions/539 + "wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539 + "wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513 + "wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263 + "wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243 + "wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337 + "wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735 + "wsdcg_g2y6z3p3ja2qhyav", # https://github.com/home-assistant/core/issues/102769 + "wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975 + "ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288 + "ywbj_gf9dejhmzffgdyfj", # https://github.com/home-assistant/core/issues/149704 + "ywcgq_h8lvyoahr6s6aybf", # https://github.com/home-assistant/core/issues/145932 + "ywcgq_wtzwyhkev3b4ubns", # https://github.com/home-assistant/core/issues/103818 + "zndb_4ggkyflayu1h1ho9", # https://github.com/home-assistant/core/pull/149317 + "zndb_ze8faryrxr0glqnn", # https://github.com/home-assistant/core/issues/138372 + "zwjcy_myd45weu", # https://github.com/orgs/home-assistant/discussions/482 +] class MockDeviceListener(DeviceListener): diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 59a6d6c27bd..08ede9b73d9 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -144,7 +144,7 @@ async def mock_devices(hass: HomeAssistant) -> list[CustomerDevice]: Use this to generate global snapshots for each platform. """ - return [await _create_device(hass, key) for key in DEVICE_MOCKS] + return [await _create_device(hass, device_code) for device_code in DEVICE_MOCKS] @pytest.fixture From bf33e286d6f574595e097fda9098806e00ebb260 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 10 Aug 2025 08:32:25 -1000 Subject: [PATCH 1076/1118] Add recovery test logic for connection failure for APCUPSD (#150382) Co-authored-by: Joost Lekkerkerker --- .../components/apcupsd/quality_scale.yaml | 1 - tests/components/apcupsd/test_config_flow.py | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apcupsd/quality_scale.yaml b/homeassistant/components/apcupsd/quality_scale.yaml index 6584a9b9461..6c71cb16b5d 100644 --- a/homeassistant/components/apcupsd/quality_scale.yaml +++ b/homeassistant/components/apcupsd/quality_scale.yaml @@ -11,7 +11,6 @@ rules: status: done comment: | Consider looking into making a `mock_setup_entry` fixture that just automatically do this. - `test_config_flow_cannot_connect`: Needs to end in CREATE_ENTRY to test that its able to recover. config-flow: done dependency-transparency: done docs-actions: diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 768d6c71ff5..9daa8337341 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -229,7 +229,7 @@ async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test reconfiguration with connection error.""" + """Test reconfiguration with connection error and recovery.""" mock_entry = MockConfigEntry( version=1, domain=DOMAIN, @@ -257,6 +257,22 @@ async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"]["base"] == "cannot_connect" + # Test recovery by fixing the connection issue. + with ( + patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, + ), + _patch_setup(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_conf_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_entry.data == new_conf_data + @pytest.mark.parametrize( ("unique_id_before", "unique_id_after"), From 69ace08c01439a97b82f202b2d4d955ff2ca9f9d Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sun, 10 Aug 2025 20:57:03 +0200 Subject: [PATCH 1077/1118] Bump solarlog_cli to 0.5.0 (#150384) --- homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/solarlog/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 486b30edfd3..4a4101a2dd3 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.4.0"] + "requirements": ["solarlog_cli==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e32d350bbe..a41f2809f2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ soco==0.30.11 solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.4.0 +solarlog_cli==0.5.0 # homeassistant.components.solax solax==3.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1ead8b83b..2d83bbfad62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2315,7 +2315,7 @@ snapcast==2.3.6 soco==0.30.11 # homeassistant.components.solarlog -solarlog_cli==0.4.0 +solarlog_cli==0.5.0 # homeassistant.components.solax solax==3.2.3 diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 6aef72ebbd5..5d91407dbbf 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ }), 'solarlog_data': dict({ 'alternator_loss': 2.0, + 'battery_data': None, 'capacity': 85.5, 'consumption_ac': 54.87, 'consumption_day': 5.31, From 79cfea3feafde05313f01df2d9d3490742ab9864 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 10 Aug 2025 09:35:47 -1000 Subject: [PATCH 1078/1118] Use `mock_setup_entry` fixture for APCUPSD (#150392) --- tests/components/apcupsd/conftest.py | 15 ++++ tests/components/apcupsd/test_config_flow.py | 92 +++++++++----------- 2 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 tests/components/apcupsd/conftest.py diff --git a/tests/components/apcupsd/conftest.py b/tests/components/apcupsd/conftest.py new file mode 100644 index 00000000000..533694fdb1f --- /dev/null +++ b/tests/components/apcupsd/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the APC UPS Daemon (APCUPSD) tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.apcupsd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 9daa8337341..6ecaa533423 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -17,13 +17,6 @@ from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS from tests.common import MockConfigEntry -def _patch_setup(): - return patch( - "homeassistant.components.apcupsd.async_setup_entry", - return_value=True, - ) - - async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" with patch( @@ -40,7 +33,9 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "cannot_connect" -async def test_config_flow_duplicate_host_port(hass: HomeAssistant) -> None: +async def test_config_flow_duplicate_host_port( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test duplicate config flow setup with the same host / port.""" # First add an existing config entry to hass. mock_entry = MockConfigEntry( @@ -53,12 +48,9 @@ async def test_config_flow_duplicate_host_port(hass: HomeAssistant) -> None: ) mock_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_request_status, - _patch_setup(), - ): + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: # Assign the same host and port, which we should reject since the entry already exists. mock_request_status.return_value = MOCK_STATUS result = await hass.config_entries.flow.async_init( @@ -81,7 +73,9 @@ async def test_config_flow_duplicate_host_port(hass: HomeAssistant) -> None: assert result["data"] == another_host -async def test_config_flow_duplicate_serial_number(hass: HomeAssistant) -> None: +async def test_config_flow_duplicate_serial_number( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test duplicate config flow setup with different host but the same serial number.""" # First add an existing config entry to hass. mock_entry = MockConfigEntry( @@ -94,12 +88,9 @@ async def test_config_flow_duplicate_serial_number(hass: HomeAssistant) -> None: ) mock_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_request_status, - _patch_setup(), - ): + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: # Assign the different host and port, but we should still reject the creation since the # serial number is the same as the existing entry. mock_request_status.return_value = MOCK_STATUS @@ -123,14 +114,11 @@ async def test_config_flow_duplicate_serial_number(hass: HomeAssistant) -> None: assert result["data"] == another_host -async def test_flow_works(hass: HomeAssistant) -> None: +async def test_flow_works(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test successful creation of config entries via user configuration.""" - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", - return_value=MOCK_STATUS, - ), - _patch_setup() as mock_setup, + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -147,7 +135,7 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA assert result["result"].unique_id == MOCK_STATUS["SERIALNO"] - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() @pytest.mark.parametrize( @@ -162,19 +150,19 @@ async def test_flow_works(hass: HomeAssistant) -> None: ], ) async def test_flow_minimal_status( - hass: HomeAssistant, extra_status: dict[str, str], expected_title: str + hass: HomeAssistant, + extra_status: dict[str, str], + expected_title: str, + mock_setup_entry: AsyncMock, ) -> None: """Test successful creation of config entries via user configuration when minimal status is reported. We test different combinations of minimal statuses, where the title of the integration will vary. """ - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" - ) as mock_request_status, - _patch_setup() as mock_setup, - ): + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status" + ) as mock_request_status: status = MOCK_MINIMAL_STATUS | extra_status mock_request_status.return_value = status @@ -185,10 +173,12 @@ async def test_flow_minimal_status( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == CONF_DATA assert result["title"] == expected_title - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() -async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: +async def test_reconfigure_flow_works( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test successful reconfiguration of an existing entry.""" mock_entry = MockConfigEntry( version=1, @@ -207,18 +197,15 @@ async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: # New configuration data with different host/port. new_conf_data = {CONF_HOST: "new_host", CONF_PORT: 4321} - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", - return_value=MOCK_STATUS, - ), - _patch_setup() as mock_setup, + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=new_conf_data ) await hass.async_block_till_done() - mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -228,7 +215,9 @@ async def test_reconfigure_flow_works(hass: HomeAssistant) -> None: assert mock_entry.data[CONF_PORT] == new_conf_data[CONF_PORT] -async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test reconfiguration with connection error and recovery.""" mock_entry = MockConfigEntry( version=1, @@ -258,12 +247,9 @@ async def test_reconfigure_flow_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "cannot_connect" # Test recovery by fixing the connection issue. - with ( - patch( - "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", - return_value=MOCK_STATUS, - ), - _patch_setup(), + with patch( + "homeassistant.components.apcupsd.coordinator.aioapcaccess.request_status", + return_value=MOCK_STATUS, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=new_conf_data From b760bf342abe93b428b2fdb4415f4b3789e007cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 21:37:14 +0200 Subject: [PATCH 1079/1118] Add frost protection and valve status to Tuya thermostats (#150177) --- .../components/tuya/binary_sensor.py | 9 ++ homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/icons.json | 3 + homeassistant/components/tuya/strings.json | 6 + homeassistant/components/tuya/switch.py | 5 + .../tuya/snapshots/test_binary_sensor.ambr | 48 ++++++ .../tuya/snapshots/test_switch.ambr | 144 ++++++++++++++++++ 7 files changed, 217 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index defbddb381d..f9bc973f5a1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -324,6 +324,15 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { on_value="alarm", ), ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + TuyaBinarySensorEntityDescription( + key=DPCode.VALVE_STATE, + translation_key="valve", + on_value="open", + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 38661d548a7..62a6c904a1f 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -209,6 +209,7 @@ class DPCode(StrEnum): FLOODLIGHT_LIGHTNESS = "floodlight_lightness" FLOODLIGHT_SWITCH = "floodlight_switch" FORWARD_ENERGY_TOTAL = "forward_energy_total" + FROST = "frost" # Frost protection GAS_SENSOR_STATE = "gas_sensor_state" GAS_SENSOR_STATUS = "gas_sensor_status" GAS_SENSOR_VALUE = "gas_sensor_value" @@ -408,6 +409,7 @@ class DPCode(StrEnum): VA_BATTERY = "va_battery" VA_HUMIDITY = "va_humidity" VA_TEMPERATURE = "va_temperature" + VALVE_STATE = "valve_state" VOC_STATE = "voc_state" VOC_VALUE = "voc_value" VOICE_SWITCH = "voice_switch" diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index 40bbf41fd0d..04a701b4764 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -15,6 +15,9 @@ }, "tilt": { "default": "mdi:spirit-level" + }, + "valve": { + "default": "mdi:pipe-valve" } }, "button": { diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 16e7e555485..c8268484c3a 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -63,6 +63,9 @@ "defrost": { "name": "Defrost" }, + "valve": { + "name": "Valve" + }, "wet": { "name": "Wet" } @@ -892,6 +895,9 @@ }, "siren": { "name": "Siren" + }, + "frost_protection": { + "name": "Frost protection" } } }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 6be878edbca..81062a092ca 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -802,6 +802,11 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), + SwitchEntityDescription( + key=DPCode.FROST, + translation_key="frost_protection", + entity_category=EntityCategory.CONFIG, + ), ), # Two-way temperature and humidity switch # "MOES Temperature and Humidity Smart Switch Module MS-103" diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index e464db94eaf..26bfd9e0d42 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -783,6 +783,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_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.smart_thermostats_valve', + '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': 'Valve', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'valve', + 'unique_id': 'tuya.sb3zdertrw50bgogkwvalve_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Valve', + }), + 'context': , + 'entity_id': 'binary_sensor.smart_thermostats_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 30c54d25bbc..57efe39fcd7 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2080,6 +2080,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.kabinet_frost_protection-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': , + 'entity_id': 'switch.kabinet_frost_protection', + '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': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.dn7cjik6kwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.kabinet_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Кабінет Frost protection', + }), + 'context': , + 'entity_id': 'switch.kabinet_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.kalado_air_purifier_filter_cartridge_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3047,6 +3095,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_frost_protection-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': , + 'entity_id': 'switch.smart_thermostats_frost_protection', + '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': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.sb3zdertrw50bgogkwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.smart_thermostats_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'smart thermostats Frost protection', + }), + 'context': , + 'entity_id': 'switch.smart_thermostats_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.socket3_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3771,6 +3867,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_frost_protection-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': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_frost_protection', + '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': 'Frost protection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'tuya.j6mn1t4ut5end6ifkwfrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Frost protection', + }), + 'context': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.xoca_dac212xc_v2_s1_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c2b284de2df830698bbf027334c32f36b85e3c14 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Sun, 10 Aug 2025 21:55:20 +0200 Subject: [PATCH 1080/1118] Add humidity (steamer) control to Huum (#150330) Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- homeassistant/components/huum/const.py | 2 +- homeassistant/components/huum/icons.json | 13 ++++ homeassistant/components/huum/number.py | 64 +++++++++++++++ homeassistant/components/huum/strings.json | 5 ++ .../huum/snapshots/test_number.ambr | 58 ++++++++++++++ tests/components/huum/test_number.py | 77 +++++++++++++++++++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huum/icons.json create mode 100644 homeassistant/components/huum/number.py create mode 100644 tests/components/huum/snapshots/test_number.ambr create mode 100644 tests/components/huum/test_number.py diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 13663d31cd0..177c035f041 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,7 +4,7 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER] CONFIG_STEAMER = 1 CONFIG_LIGHT = 2 diff --git a/homeassistant/components/huum/icons.json b/homeassistant/components/huum/icons.json new file mode 100644 index 00000000000..4281cdbde2a --- /dev/null +++ b/homeassistant/components/huum/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "number": { + "humidity": { + "default": "mdi:water", + "range": { + "0": "mdi:water-off", + "1": "mdi:water" + } + } + } + } +} diff --git a/homeassistant/components/huum/number.py b/homeassistant/components/huum/number.py new file mode 100644 index 00000000000..daaf348c029 --- /dev/null +++ b/homeassistant/components/huum/number.py @@ -0,0 +1,64 @@ +"""Control for steamer.""" + +from __future__ import annotations + +import logging + +from huum.const import SaunaStatus + +from homeassistant.components.number import NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONFIG_STEAMER, CONFIG_STEAMER_AND_LIGHT +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up steamer if applicable.""" + coordinator = config_entry.runtime_data + + # Light is configured for this sauna. + if coordinator.data.config in [CONFIG_STEAMER, CONFIG_STEAMER_AND_LIGHT]: + async_add_entities([HuumSteamer(coordinator)]) + + +class HuumSteamer(HuumBaseEntity, NumberEntity): + """Representation of a steamer.""" + + _attr_translation_key = "humidity" + _attr_native_max_value = 10 + _attr_native_min_value = 0 + _attr_native_step = 1 + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the steamer.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.coordinator.data.humidity + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + target_temperature = self.coordinator.data.target_temperature + if ( + not target_temperature + or self.coordinator.data.status != SaunaStatus.ONLINE_HEATING + ): + return + + await self.coordinator.huum.turn_on( + temperature=target_temperature, humidity=int(value) + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/huum/strings.json b/homeassistant/components/huum/strings.json index 55ccf0fdd81..13c2e5c85f6 100644 --- a/homeassistant/components/huum/strings.json +++ b/homeassistant/components/huum/strings.json @@ -24,6 +24,11 @@ "light": { "name": "[%key:component::light::title%]" } + }, + "number": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } } } } diff --git a/tests/components/huum/snapshots/test_number.ambr b/tests/components/huum/snapshots/test_number.ambr new file mode 100644 index 00000000000..19c0642f007 --- /dev/null +++ b/tests/components/huum/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_number_entity[number.huum_sauna_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.huum_sauna_humidity', + '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': 'Humidity', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entity[number.huum_sauna_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Huum sauna Humidity', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.huum_sauna_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- diff --git a/tests/components/huum/test_number.py b/tests/components/huum/test_number.py new file mode 100644 index 00000000000..3d7a74bfce3 --- /dev/null +++ b/tests/components/huum/test_number.py @@ -0,0 +1,77 @@ +"""Tests for the Huum number entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "number.huum_sauna_humidity" + + +async def test_number_entity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_humidity( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the humidity.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_VALUE: 5, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(temperature=80, humidity=5) + + +async def test_dont_set_humidity_when_sauna_not_heating( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the humidity.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + + mock_huum.status = SaunaStatus.ONLINE_NOT_HEATING + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_VALUE: 5, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_not_called() From 38e6a7c6d49e63947e0aaf071f87bc7649c4b70e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 22:17:14 +0200 Subject: [PATCH 1081/1118] Add Tuya test fixtures (#150387) --- tests/components/tuya/__init__.py | 75 +- .../tuya/fixtures/cobj_hcdy5zrq3ikzthws.json | 59 + .../tuya/fixtures/cz_2iepauebcvo74ujc.json | 168 + .../tuya/fixtures/cz_37mnhia3pojleqfh.json | 87 + .../tuya/fixtures/cz_39sy2g68gsjwo2xv.json | 155 + .../tuya/fixtures/cz_6fa7odsufen374x2.json | 168 + .../tuya/fixtures/cz_9ivirni8wemum6cw.json | 87 + .../tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json | 56 + .../tuya/fixtures/cz_anwgf2xugjxpkfxb.json | 116 + .../tuya/fixtures/cz_gbtxrqfy9xcsakyp.json | 168 + .../components/tuya/fixtures/cz_gjnozsaz.json | 133 + .../tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json | 54 + .../tuya/fixtures/cz_ik9sbig3mthx9hjz.json | 168 + .../tuya/fixtures/cz_jnbbxsb84gvvyfg5.json | 56 + .../tuya/fixtures/cz_n8iVBAPLFKAAAszH.json | 54 + .../tuya/fixtures/cz_nkb0fmtlfyqosnvk.json | 98 + .../tuya/fixtures/cz_nx8rv6jpe1tsnffk.json | 116 + .../tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json | 168 + .../tuya/fixtures/cz_raceucn29wk2yawe.json | 56 + .../tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json | 168 + .../tuya/fixtures/cz_tf6qp8t3hl9h7m94.json | 86 + .../tuya/fixtures/cz_tkn2s79mzedk6pwr.json | 160 + .../tuya/fixtures/cz_vxqn72kwtosoy4d3.json | 98 + .../components/tuya/fixtures/cz_w0qqde0g.json | 133 + .../tuya/fixtures/cz_wifvoilfrqeo6hvu.json | 98 + .../tuya/fixtures/cz_wrz6vzch8htux2zp.json | 168 + .../components/tuya/fixtures/cz_y4jnobxh.json | 33 + .../tuya/fixtures/dj_0gyaslysqfp4gfis.json | 557 ++ .../tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json | 508 ++ .../tuya/fixtures/dj_amx1bgdrfab6jngb.json | 333 + .../tuya/fixtures/dj_bSXSSFArVKtc4DyC.json | 54 + .../tuya/fixtures/dj_c3nsqogqovapdpfj.json | 348 + .../components/tuya/fixtures/dj_dbou1ap4.json | 390 ++ .../tuya/fixtures/dj_tgewj70aowigv8fz.json | 140 + .../tuya/fixtures/dj_xdvitmhhmgefaeuq.json | 510 ++ .../tuya/fixtures/dlq_jdj6ccklup7btq3a.json | 216 + .../tuya/fixtures/dlq_r9kg2g1uhhyicycb.json | 143 + .../tuya/fixtures/dr_pjvxl1wsyqxivsaf.json | 185 + .../tuya/fixtures/hps_wqashyqo.json | 41 + .../tuya/fixtures/kg_4nqs33emdwJxpQ8O.json | 54 + .../components/tuya/fixtures/kg_5ftkaulg.json | 80 + .../tuya/fixtures/kj_s4uzibibgzdxzowo.json | 115 + .../tuya/fixtures/mcs_6ywsnauy.json | 44 + .../tuya/fixtures/mcs_8yhypbo7.json | 39 + .../tuya/fixtures/mcs_hx5ztlztij4yxxvg.json | 35 + .../tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json | 39 + .../tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json | 21 + .../tuya/fixtures/pc_tsbguim4trl6fa7g.json | 188 + .../tuya/fixtures/pc_yku9wsimasckdt15.json | 189 + .../tuya/fixtures/sd_i6hyjg3af7doaswm.json | 64 + .../tuya/fixtures/sfkzq_1fcnd8xk.json | 130 + .../tuya/fixtures/sfkzq_rzklytdei8i8vo37.json | 100 + .../tuya/fixtures/sp_nzauwyj3mcnjnf35.json | 220 + .../tuya/fixtures/sp_rudejjigkywujjvs.json | 240 + .../tuya/fixtures/wfcon_lieerjyy6l4ykjor.json | 21 + .../tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json | 227 + .../tuya/fixtures/wnykq_npbbca46yiug8ysk.json | 21 + .../tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json | 21 + .../tuya/fixtures/wsdcg_iq4ygaai.json | 45 + .../tuya/fixtures/wsdcg_iv7hudlj.json | 168 + .../tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json | 66 + .../tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json | 66 + .../tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json | 56 + .../tuya/fixtures/wsdcg_xr3htd96.json | 56 + .../tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json | 259 + .../tuya/fixtures/wxkg_ja5osu5g.json | 64 + .../tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json | 63 + .../tuya/fixtures/ykq_bngwdjsr.json | 70 + .../tuya/fixtures/ywbj_arywmw6h6vesoz5t.json | 37 + .../tuya/fixtures/ywbj_cjlutkuuvxnie17o.json | 37 + .../tuya/fixtures/ywbj_kscbebaf3s1eogvt.json | 51 + .../tuya/fixtures/zjq_nkkl7uzv.json | 21 + .../tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json | 77 + .../tuya/fixtures/znrb_db81ge24jctwx8lo.json | 172 + .../tuya/snapshots/test_binary_sensor.ambr | 490 ++ .../tuya/snapshots/test_camera.ambr | 107 + .../components/tuya/snapshots/test_event.ambr | 61 + tests/components/tuya/snapshots/test_fan.ambr | 50 + .../components/tuya/snapshots/test_light.ambr | 567 ++ .../tuya/snapshots/test_number.ambr | 59 + .../tuya/snapshots/test_select.ambr | 2588 +++++++ .../tuya/snapshots/test_sensor.ambr | 6111 +++++++++++++++++ .../components/tuya/snapshots/test_siren.ambr | 49 + .../tuya/snapshots/test_switch.ambr | 3935 +++++++++++ .../tuya/snapshots/test_vacuum.ambr | 49 + 85 files changed, 23632 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json create mode 100644 tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json create mode 100644 tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json create mode 100644 tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json create mode 100644 tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json create mode 100644 tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json create mode 100644 tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json create mode 100644 tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json create mode 100644 tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json create mode 100644 tests/components/tuya/fixtures/cz_gjnozsaz.json create mode 100644 tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json create mode 100644 tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json create mode 100644 tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json create mode 100644 tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json create mode 100644 tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json create mode 100644 tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json create mode 100644 tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json create mode 100644 tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json create mode 100644 tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json create mode 100644 tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json create mode 100644 tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json create mode 100644 tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json create mode 100644 tests/components/tuya/fixtures/cz_w0qqde0g.json create mode 100644 tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json create mode 100644 tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json create mode 100644 tests/components/tuya/fixtures/cz_y4jnobxh.json create mode 100644 tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json create mode 100644 tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json create mode 100644 tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json create mode 100644 tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json create mode 100644 tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json create mode 100644 tests/components/tuya/fixtures/dj_dbou1ap4.json create mode 100644 tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json create mode 100644 tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json create mode 100644 tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json create mode 100644 tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json create mode 100644 tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json create mode 100644 tests/components/tuya/fixtures/hps_wqashyqo.json create mode 100644 tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json create mode 100644 tests/components/tuya/fixtures/kg_5ftkaulg.json create mode 100644 tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json create mode 100644 tests/components/tuya/fixtures/mcs_6ywsnauy.json create mode 100644 tests/components/tuya/fixtures/mcs_8yhypbo7.json create mode 100644 tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json create mode 100644 tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json create mode 100644 tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json create mode 100644 tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json create mode 100644 tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json create mode 100644 tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json create mode 100644 tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json create mode 100644 tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json create mode 100644 tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json create mode 100644 tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json create mode 100644 tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json create mode 100644 tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json create mode 100644 tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json create mode 100644 tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json create mode 100644 tests/components/tuya/fixtures/wsdcg_iq4ygaai.json create mode 100644 tests/components/tuya/fixtures/wsdcg_iv7hudlj.json create mode 100644 tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json create mode 100644 tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json create mode 100644 tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json create mode 100644 tests/components/tuya/fixtures/wsdcg_xr3htd96.json create mode 100644 tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json create mode 100644 tests/components/tuya/fixtures/wxkg_ja5osu5g.json create mode 100644 tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json create mode 100644 tests/components/tuya/fixtures/ykq_bngwdjsr.json create mode 100644 tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json create mode 100644 tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json create mode 100644 tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json create mode 100644 tests/components/tuya/fixtures/zjq_nkkl7uzv.json create mode 100644 tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json create mode 100644 tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index dfce03e80ea..0abac1062e3 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -20,25 +20,57 @@ DEVICE_MOCKS = [ "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 "co2bj_yrr3eiyiacm31ski", # https://github.com/home-assistant/core/issues/133173 + "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 "cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098 "cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79 - "cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599 "cwwsq_wfkzyy0evslzsmoi", # https://github.com/home-assistant/core/issues/144745 + "cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599 "cwysj_z3rpyvznfcch99aa", # https://github.com/home-assistant/core/pull/146599 "cz_0g1fmqh6d5io7lcn", # https://github.com/home-assistant/core/issues/149704 + "cz_2iepauebcvo74ujc", # https://github.com/home-assistant/core/issues/141278 "cz_2jxesipczks0kdct", # https://github.com/home-assistant/core/issues/147149 + "cz_37mnhia3pojleqfh", # https://github.com/home-assistant/core/issues/146164 + "cz_39sy2g68gsjwo2xv", # https://github.com/home-assistant/core/issues/141278 + "cz_6fa7odsufen374x2", # https://github.com/home-assistant/core/issues/150029 + "cz_9ivirni8wemum6cw", # https://github.com/home-assistant/core/issues/139735 + "cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539 "cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704 "cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704 + "cz_gbtxrqfy9xcsakyp", # https://github.com/home-assistant/core/issues/141278 + "cz_gjnozsaz", # https://github.com/orgs/home-assistant/discussions/482 + "cz_hA2GsgMfTQFTz9JL", # https://github.com/home-assistant/core/issues/148347 "cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704 + "cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278 + "cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164 + "cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482 + "cz_nx8rv6jpe1tsnffk", # https://github.com/home-assistant/core/issues/148347 + "cz_qm0iq4nqnrlzh4qc", # https://github.com/home-assistant/core/issues/141278 + "cz_raceucn29wk2yawe", # https://github.com/tuya/tuya-home-assistant/issues/754 + "cz_sb6bwb1n8ma2c5q4", # https://github.com/home-assistant/core/issues/141278 "cz_t0a4hwsf8anfsadp", # https://github.com/home-assistant/core/issues/149704 + "cz_tf6qp8t3hl9h7m94", # https://github.com/home-assistant/core/issues/143209 + "cz_tkn2s79mzedk6pwr", # https://github.com/home-assistant/core/issues/146164 + "cz_vxqn72kwtosoy4d3", # https://github.com/home-assistant/core/issues/141278 + "cz_w0qqde0g", # https://github.com/orgs/home-assistant/discussions/482 + "cz_wifvoilfrqeo6hvu", # https://github.com/home-assistant/core/issues/146164 + "cz_wrz6vzch8htux2zp", # https://github.com/home-assistant/core/issues/141278 + "cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482 "dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704 + "dj_0gyaslysqfp4gfis", # https://github.com/home-assistant/core/issues/149895 "dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704 "dj_8y0aquaa8v6tho8w", # https://github.com/home-assistant/core/issues/149704 + "dj_AqHUMdcbYzIq1Of4", # https://github.com/orgs/home-assistant/discussions/539 + "dj_amx1bgdrfab6jngb", # https://github.com/orgs/home-assistant/discussions/482 + "dj_bSXSSFArVKtc4DyC", # https://github.com/orgs/home-assistant/discussions/539 "dj_baf9tt9lb8t5uc7z", # https://github.com/home-assistant/core/issues/149704 + "dj_c3nsqogqovapdpfj", # https://github.com/home-assistant/core/issues/146164 "dj_d4g0fbsoaal841o6", # https://github.com/home-assistant/core/issues/149704 + "dj_dbou1ap4", # https://github.com/orgs/home-assistant/discussions/482 "dj_djnozmdyqyriow8z", # https://github.com/home-assistant/core/issues/149704 "dj_ekwolitfjhxn55js", # https://github.com/home-assistant/core/issues/149704 "dj_fuupmcr2mb1odkja", # https://github.com/home-assistant/core/issues/149704 @@ -54,22 +86,31 @@ DEVICE_MOCKS = [ "dj_nlxvjzy1hoeiqsg6", # https://github.com/home-assistant/core/issues/149704 "dj_oe0cpnjg", # https://github.com/home-assistant/core/issues/149704 "dj_riwp3k79", # https://github.com/home-assistant/core/issues/149704 + "dj_tgewj70aowigv8fz", # https://github.com/orgs/home-assistant/discussions/539 "dj_tmsloaroqavbucgn", # https://github.com/home-assistant/core/issues/149704 "dj_ufq2xwuzd4nb0qdr", # https://github.com/home-assistant/core/issues/149704 "dj_vqwcnabamzrc2kab", # https://github.com/home-assistant/core/issues/149704 + "dj_xdvitmhhmgefaeuq", # https://github.com/home-assistant/core/issues/146164 "dj_xokdfs6kh5ednakk", # https://github.com/home-assistant/core/issues/149704 "dj_zakhnlpdiu0ycdxn", # https://github.com/home-assistant/core/issues/149704 "dj_zav1pa32pyxray78", # https://github.com/home-assistant/core/issues/149704 "dj_zputiamzanuk6yky", # https://github.com/home-assistant/core/issues/149704 "dlq_0tnvg2xaisqdadcf", # https://github.com/home-assistant/core/issues/102769 + "dlq_jdj6ccklup7btq3a", # https://github.com/home-assistant/core/issues/143209 "dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499 + "dlq_r9kg2g1uhhyicycb", # https://github.com/home-assistant/core/issues/149650 + "dr_pjvxl1wsyqxivsaf", # https://github.com/home-assistant/core/issues/84869 "fs_g0ewlb1vmwqljzji", # https://github.com/home-assistant/core/issues/141231 "fs_ibytpo6fpnugft1c", # https://github.com/home-assistant/core/issues/135541 "gyd_lgekqfxdabipm3tn", # https://github.com/home-assistant/core/issues/133173 "hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704 + "hps_wqashyqo", # https://github.com/home-assistant/core/issues/146180 + "kg_4nqs33emdwJxpQ8O", # https://github.com/orgs/home-assistant/discussions/539 + "kg_5ftkaulg", # https://github.com/orgs/home-assistant/discussions/539 "kg_gbm9ata1zrzaez4a", # https://github.com/home-assistant/core/issues/148347 "kj_CAjWAxBUZt7QZHfz", # https://github.com/home-assistant/core/issues/146023 "kj_fsxtzzhujkrak2oy", # https://github.com/orgs/home-assistant/discussions/439 + "kj_s4uzibibgzdxzowo", # https://github.com/home-assistant/core/issues/150246 "kj_yrzylxax1qspdgpp", # https://github.com/orgs/home-assistant/discussions/61 "ks_j9fa8ahzac8uvlfl", # https://github.com/orgs/home-assistant/discussions/329 "kt_5wnlzekkstwcdsvm", # https://github.com/home-assistant/core/pull/148646 @@ -77,10 +118,17 @@ DEVICE_MOCKS = [ "kt_vdadlnmsorlhw4td", # https://github.com/home-assistant/core/pull/149635 "ldcg_9kbbfeho", # https://github.com/orgs/home-assistant/discussions/482 "mal_gyitctrjj1kefxp2", # Alarm Host support + "mcs_6ywsnauy", # https://github.com/orgs/home-assistant/discussions/482 "mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301 + "mcs_8yhypbo7", # https://github.com/orgs/home-assistant/discussions/482 + "mcs_hx5ztlztij4yxxvg", # https://github.com/home-assistant/core/issues/148347 + "mcs_qxu3flpqjsc1kqu3", # https://github.com/home-assistant/core/issues/141278 "mzj_qavcakohisj5adyh", # https://github.com/home-assistant/core/issues/141278 + "ntq_9mqdhwklpvnnvb7t", # https://github.com/orgs/home-assistant/discussions/517 "pc_t2afic7i3v1bwhfp", # https://github.com/home-assistant/core/issues/149704 "pc_trjopo1vdlt9q1tg", # https://github.com/home-assistant/core/issues/149704 + "pc_tsbguim4trl6fa7g", # https://github.com/home-assistant/core/issues/146164 + "pc_yku9wsimasckdt15", # https://github.com/orgs/home-assistant/discussions/482 "pir_3amxzozho9xp4mkh", # https://github.com/home-assistant/core/issues/149704 "pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704 "pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704 @@ -88,12 +136,17 @@ DEVICE_MOCKS = [ "qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318 "qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472 "rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100 + "sd_i6hyjg3af7doaswm", # https://github.com/orgs/home-assistant/discussions/539 "sd_lr33znaodtyarrrz", # https://github.com/home-assistant/core/issues/141278 + "sfkzq_1fcnd8xk", # https://github.com/orgs/home-assistant/discussions/539 "sfkzq_o6dagifntoafakst", # https://github.com/home-assistant/core/issues/148116 + "sfkzq_rzklytdei8i8vo37", # https://github.com/home-assistant/core/issues/146164 "sgbj_ulv4nnue7gqp0rjk", # https://github.com/home-assistant/core/issues/149704 "sj_tgvtvdoc", # https://github.com/orgs/home-assistant/discussions/482 "sp_drezasavompxpcgm", # https://github.com/home-assistant/core/issues/149704 + "sp_nzauwyj3mcnjnf35", # https://github.com/home-assistant/core/issues/141278 "sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704 + "sp_rudejjigkywujjvs", # https://github.com/home-assistant/core/issues/146164 "sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087 "tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209 "tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209 @@ -103,6 +156,7 @@ DEVICE_MOCKS = [ "tdq_uoa3mayicscacseb", # https://github.com/home-assistant/core/issues/128911 "tyndj_pyakuuoc", # https://github.com/home-assistant/core/issues/149704 "wfcon_b25mh8sxawsgndck", # https://github.com/home-assistant/core/issues/149704 + "wfcon_lieerjyy6l4ykjor", # https://github.com/home-assistant/core/issues/136055 "wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517 "wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430 "wg2_setmxeqgs63xwopm", # https://github.com/orgs/home-assistant/discussions/539 @@ -112,14 +166,33 @@ DEVICE_MOCKS = [ "wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243 "wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337 "wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735 + "wkcz_gc4b1mdw7kebtuyz", # https://github.com/home-assistant/core/issues/135617 + "wnykq_npbbca46yiug8ysk", # https://github.com/orgs/home-assistant/discussions/539 + "wnykq_rqhxdyusjrwxyff6", # https://github.com/home-assistant/core/issues/133173 "wsdcg_g2y6z3p3ja2qhyav", # https://github.com/home-assistant/core/issues/102769 + "wsdcg_iq4ygaai", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_iv7hudlj", # https://github.com/home-assistant/core/issues/141278 + "wsdcg_krlcihrpzpc8olw9", # https://github.com/orgs/home-assistant/discussions/517 + "wsdcg_lf36y5nwb8jkxwgg", # https://github.com/orgs/home-assistant/discussions/539 + "wsdcg_vtA4pDd6PLUZzXgZ", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_xr3htd96", # https://github.com/orgs/home-assistant/discussions/482 + "wsdcg_yqiqbaldtr0i7mru", # https://github.com/home-assistant/core/issues/136223 + "wxkg_ja5osu5g", # https://github.com/orgs/home-assistant/discussions/482 "wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975 "ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288 + "ygsb_l6ax0u6jwbz82atk", # https://github.com/home-assistant/core/issues/146319 + "ykq_bngwdjsr", # https://github.com/orgs/home-assistant/discussions/482 + "ywbj_arywmw6h6vesoz5t", # https://github.com/home-assistant/core/issues/146164 + "ywbj_cjlutkuuvxnie17o", # https://github.com/home-assistant/core/issues/146164 "ywbj_gf9dejhmzffgdyfj", # https://github.com/home-assistant/core/issues/149704 + "ywbj_kscbebaf3s1eogvt", # https://github.com/home-assistant/core/issues/141278 "ywcgq_h8lvyoahr6s6aybf", # https://github.com/home-assistant/core/issues/145932 "ywcgq_wtzwyhkev3b4ubns", # https://github.com/home-assistant/core/issues/103818 + "zjq_nkkl7uzv", # https://github.com/orgs/home-assistant/discussions/482 "zndb_4ggkyflayu1h1ho9", # https://github.com/home-assistant/core/pull/149317 + "zndb_v5jlnn5hwyffkhp3", # https://github.com/home-assistant/core/issues/143209 "zndb_ze8faryrxr0glqnn", # https://github.com/home-assistant/core/issues/138372 + "znrb_db81ge24jctwx8lo", # https://github.com/home-assistant/core/issues/136513 "zwjcy_myd45weu", # https://github.com/orgs/home-assistant/discussions/482 ] diff --git a/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json b/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json new file mode 100644 index 00000000000..59e6fc63f1b --- /dev/null +++ b/tests/components/tuya/fixtures/cobj_hcdy5zrq3ikzthws.json @@ -0,0 +1,59 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smogo", + "category": "cobj", + "product_id": "hcdy5zrq3ikzthws", + "product_name": "WIFI smart CO alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-07-19T07:41:22+00:00", + "create_time": "2023-07-19T07:41:22+00:00", + "update_time": "2023-07-19T07:41:22+00:00", + "function": {}, + "status_range": { + "co_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "checking_result": { + "type": "Enum", + "value": { + "range": ["checking", "check_success", "check_failure", "others"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "co_status": "normal", + "co_value": 0, + "checking_result": "check_success", + "battery_percentage": 97 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json b/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json new file mode 100644 index 00000000000..e0e41a2ca7e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_2iepauebcvo74ujc.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Aubess Cooker", + "category": "cz", + "product_id": "2iepauebcvo74ujc", + "product_name": "Aubess Smart\u00a0Socket 20A/EM", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-02-03T19:37:03+00:00", + "create_time": "2023-02-03T19:37:03+00:00", + "update_time": "2023-02-03T19:37:03+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 1574, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json b/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json new file mode 100644 index 00000000000..32ba4caf81a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_37mnhia3pojleqfh.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sapphire ", + "category": "cz", + "product_id": "37mnhia3pojleqfh", + "product_name": "SP111", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:54:29+00:00", + "create_time": "2025-03-15T13:54:29+00:00", + "update_time": "2025-03-15T13:54:29+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "s", + "max": 86400, + "step": 1 + } + } + }, + "status_range": { + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "s", + "max": 86400, + "step": 1 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "mA", + "max": 30000, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "min": 0, + "unit": "V", + "scale": 0, + "max": 2500, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "W", + "max": 50000, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "cur_current": 135, + "cur_power": 313, + "cur_voltage": 2357 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json b/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json new file mode 100644 index 00000000000..2d067f678f7 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_39sy2g68gsjwo2xv.json @@ -0,0 +1,155 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Ineox SP2", + "category": "cz", + "product_id": "39sy2g68gsjwo2xv", + "product_name": "Ineox SP2", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-03-18T15:08:17+00:00", + "create_time": "2023-03-18T15:08:17+00:00", + "update_time": "2023-03-18T15:08:17+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 3, + "cur_current": 228, + "cur_power": 61, + "cur_voltage": 2321, + "relay_status": "last", + "overcharge_switch": false, + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json b/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json new file mode 100644 index 00000000000..0174eb71ca9 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_6fa7odsufen374x2.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Office", + "category": "cz", + "product_id": "6fa7odsufen374x2", + "product_name": "5GHz plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-08-04T14:58:27+00:00", + "create_time": "2025-08-04T14:58:27+00:00", + "update_time": "2025-08-04T14:58:27+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 13, + "cur_current": 253, + "cur_power": 389, + "cur_voltage": 2396, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json b/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json new file mode 100644 index 00000000000..89643d828cf --- /dev/null +++ b/tests/components/tuya/fixtures/cz_9ivirni8wemum6cw.json @@ -0,0 +1,87 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Gar\u00e1\u017e \u010derpadlo", + "category": "cz", + "product_id": "9ivirni8wemum6cw", + "product_name": "Smart Socket", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-07-18T15:41:56+00:00", + "create_time": "2022-07-18T15:41:56+00:00", + "update_time": "2022-07-18T15:41:56+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2407 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json b/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json new file mode 100644 index 00000000000..8a0ede73696 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_HBRBzv1UVBVfF6SL.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rewireable Plug 6930HA", + "model": null, + "category": "cz", + "product_id": "HBRBzv1UVBVfF6SL", + "product_name": "Rewireable Plug 6930HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-07-27T08:43:25+00:00", + "create_time": "2021-07-27T08:43:25+00:00", + "update_time": "2022-02-12T10:40:12+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json b/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json new file mode 100644 index 00000000000..4a22e6f59c0 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_anwgf2xugjxpkfxb.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Security Light", + "category": "cz", + "product_id": "anwgf2xugjxpkfxb", + "product_name": "Smart Socket ", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2024-04-08T16:20:27+00:00", + "create_time": "2024-04-08T16:20:27+00:00", + "update_time": "2024-04-08T16:20:27+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "relay_status": "power_on", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json b/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json new file mode 100644 index 00000000000..456c2b1fa60 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_gbtxrqfy9xcsakyp.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "3DPrinter", + "category": "cz", + "product_id": "gbtxrqfy9xcsakyp", + "product_name": "Smart Plug+", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-08-16T08:48:56+00:00", + "create_time": "2023-08-16T08:48:56+00:00", + "update_time": "2023-08-16T08:48:56+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2319, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_gjnozsaz.json b/tests/components/tuya/fixtures/cz_gjnozsaz.json new file mode 100644 index 00000000000..dab20faf3c7 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_gjnozsaz.json @@ -0,0 +1,133 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Raspy4 - Home Assistant", + "category": "cz", + "product_id": "gjnozsaz", + "product_name": "Smart plug", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:52:30+00:00", + "create_time": "2025-07-19T09:52:30+00:00", + "update_time": "2025-07-19T09:52:30+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 100000000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1500, + "cur_current": 33, + "cur_power": 30, + "cur_voltage": 2440, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json b/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json new file mode 100644 index 00000000000..d0d7001fc02 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_hA2GsgMfTQFTz9JL.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Spot 4", + "category": "cz", + "product_id": "hA2GsgMfTQFTz9JL", + "product_name": "Mini Smart Socket", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-19T17:17:15+00:00", + "create_time": "2025-06-19T17:17:15+00:00", + "update_time": "2025-06-19T17:17:15+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json b/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json new file mode 100644 index 00000000000..905f4270d18 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_ik9sbig3mthx9hjz.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Aubess Washing Machine", + "category": "cz", + "product_id": "ik9sbig3mthx9hjz", + "product_name": "Aubess Smart\u00a0Socket EM", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-18T10:29:20+00:00", + "create_time": "2024-08-18T10:29:20+00:00", + "update_time": "2024-08-18T10:29:20+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2299, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json b/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json new file mode 100644 index 00000000000..03edf52d3c4 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_jnbbxsb84gvvyfg5.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Fan", + "model": "6920HA", + "category": "cz", + "product_id": "jnbbxsb84gvvyfg5", + "product_name": "Plug Base 6210HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-08-23T20:40:36+00:00", + "create_time": "2021-08-18T13:14:59+00:00", + "update_time": "2022-02-12T10:40:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json b/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json new file mode 100644 index 00000000000..4de85bb849f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_n8iVBAPLFKAAAszH.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Steckdose 2", + "category": "cz", + "product_id": "n8iVBAPLFKAAAszH", + "product_name": "Socket", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:17:22+00:00", + "create_time": "2025-03-15T13:17:22+00:00", + "update_time": "2025-03-15T13:17:22+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json b/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json new file mode 100644 index 00000000000..f0fe165ecb8 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_nkb0fmtlfyqosnvk.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bassin", + "category": "cz", + "product_id": "nkb0fmtlfyqosnvk", + "product_name": "Konyks Pluviose Easy EU", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-04-11T16:27:36+00:00", + "create_time": "2024-04-11T16:27:36+00:00", + "update_time": "2024-04-11T16:27:36+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 21, + "cur_current": 783, + "cur_power": 411, + "cur_voltage": 2454 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json b/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json new file mode 100644 index 00000000000..277a2fbe81e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_nx8rv6jpe1tsnffk.json @@ -0,0 +1,116 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Spot 1", + "category": "cz", + "product_id": "nx8rv6jpe1tsnffk", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-21T17:03:23+00:00", + "create_time": "2025-06-21T17:03:23+00:00", + "update_time": "2025-06-21T17:03:23+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json b/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json new file mode 100644 index 00000000000..167878abc59 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_qm0iq4nqnrlzh4qc.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Elivco Kitchen Socket", + "category": "cz", + "product_id": "qm0iq4nqnrlzh4qc", + "product_name": "Smart plug", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-03-29T15:03:22+00:00", + "create_time": "2023-03-29T15:03:22+00:00", + "update_time": "2023-03-29T15:03:22+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 24, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2334, + "relay_status": "power_on", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json b/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json new file mode 100644 index 00000000000..a77bfd79d6e --- /dev/null +++ b/tests/components/tuya/fixtures/cz_raceucn29wk2yawe.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "61", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Mirror", + "model": "", + "category": "cz", + "product_id": "raceucn29wk2yawe", + "product_name": "Inline Switch 6000HA", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2021-08-18T13:03:59+00:00", + "create_time": "2021-08-18T13:03:59+00:00", + "update_time": "2022-02-12T10:40:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0 + } +} diff --git a/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json b/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json new file mode 100644 index 00000000000..b077af094fa --- /dev/null +++ b/tests/components/tuya/fixtures/cz_sb6bwb1n8ma2c5q4.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Socket4", + "category": "cz", + "product_id": "sb6bwb1n8ma2c5q4", + "product_name": "WIFI \u63d2\u5ea7", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-16T18:50:14+00:00", + "create_time": "2025-01-16T18:50:14+00:00", + "update_time": "2025-01-16T18:50:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 0, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2325, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json b/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json new file mode 100644 index 00000000000..22db23d06dc --- /dev/null +++ b/tests/components/tuya/fixtures/cz_tf6qp8t3hl9h7m94.json @@ -0,0 +1,86 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Consommation", + "category": "cz", + "product_id": "tf6qp8t3hl9h7m94", + "product_name": "smart meter with CT-2", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-18T08:10:00+00:00", + "create_time": "2025-04-18T08:10:00+00:00", + "update_time": "2025-04-18T08:10:00+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "A", + "min": 0, + "max": 80000, + "scale": 3, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "switch_2": false, + "add_ele": 100, + "cur_current": 2585, + "cur_power": 4258, + "cur_voltage": 2416 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json b/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json new file mode 100644 index 00000000000..4a551736c3f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_tkn2s79mzedk6pwr.json @@ -0,0 +1,160 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Weihnachtsmann ", + "category": "cz", + "product_id": "tkn2s79mzedk6pwr", + "product_name": "Smart Socket ", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-28T18:55:54+00:00", + "create_time": "2023-11-28T18:55:54+00:00", + "update_time": "2023-11-28T18:55:54+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 80000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "add_ele": 4, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json b/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json new file mode 100644 index 00000000000..3b4b98514ba --- /dev/null +++ b/tests/components/tuya/fixtures/cz_vxqn72kwtosoy4d3.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Socket", + "category": "cz", + "product_id": "vxqn72kwtosoy4d3", + "product_name": "Smart Plug+", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-11T13:38:52+00:00", + "create_time": "2024-01-11T13:38:52+00:00", + "update_time": "2024-01-11T13:38:52+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 80000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 2, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2350 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_w0qqde0g.json b/tests/components/tuya/fixtures/cz_w0qqde0g.json new file mode 100644 index 00000000000..6d960603ba1 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_w0qqde0g.json @@ -0,0 +1,133 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lave linge", + "category": "cz", + "product_id": "w0qqde0g", + "product_name": "Smart plug", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T08:38:51+00:00", + "create_time": "2025-07-19T08:38:51+00:00", + "update_time": "2025-07-19T08:38:51+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 100000000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 62860, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2440, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json b/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json new file mode 100644 index 00000000000..e0912445003 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_wifvoilfrqeo6hvu.json @@ -0,0 +1,98 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Licht drucker", + "category": "cz", + "product_id": "wifvoilfrqeo6hvu", + "product_name": "Smart Socket", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:48:41+00:00", + "create_time": "2025-03-15T13:48:41+00:00", + "update_time": "2025-03-15T13:48:41+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u79d2", + "max": 86400, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u79d2", + "max": 86400, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "\u5ea6", + "max": 500000, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "mA", + "max": 30000, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "W", + "max": 50000, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "min": 0, + "unit": "V", + "scale": 0, + "max": 2500, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 10, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2346 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json b/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json new file mode 100644 index 00000000000..29cb9488745 --- /dev/null +++ b/tests/components/tuya/fixtures/cz_wrz6vzch8htux2zp.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Elivco TV", + "category": "cz", + "product_id": "wrz6vzch8htux2zp", + "product_name": "WiFi Plug", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-02-20T12:10:18+00:00", + "create_time": "2023-02-20T12:10:18+00:00", + "update_time": "2023-02-20T12:10:18+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "overcharge_switch": { + "type": "Boolean", + "value": {} + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "countdown_1": 0, + "add_ele": 6, + "cur_current": 91, + "cur_power": 100, + "cur_voltage": 2377, + "relay_status": "last", + "overcharge_switch": false, + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_y4jnobxh.json b/tests/components/tuya/fixtures/cz_y4jnobxh.json new file mode 100644 index 00000000000..27680e4521a --- /dev/null +++ b/tests/components/tuya/fixtures/cz_y4jnobxh.json @@ -0,0 +1,33 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "AuVeLiCo", + "category": "cz", + "product_id": "y4jnobxh", + "product_name": "\u3010\u901a\u7528\u63a5\u5165\u30111\u8def\u63d2\u5ea7", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:30:43+00:00", + "create_time": "2025-07-19T09:30:43+00:00", + "update_time": "2025-07-19T09:30:43+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch_1": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json b/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json new file mode 100644 index 00000000000..3ab0f17cb9d --- /dev/null +++ b/tests/components/tuya/fixtures/dj_0gyaslysqfp4gfis.json @@ -0,0 +1,557 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Study 1", + "category": "dj", + "product_id": "0gyaslysqfp4gfis", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2025-06-29T07:45:01+00:00", + "create_time": "2025-06-29T07:45:01+00:00", + "update_time": "2025-06-29T07:45:01+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "power_memory": { + "type": "Raw", + "value": {} + }, + "do_not_disturb": { + "type": "Boolean", + "value": {} + }, + "remote_switch": { + "type": "Boolean", + "value": {} + }, + "cycle_timing": { + "type": "Raw", + "value": {} + }, + "random_timing": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=", + "power_memory": "AAEAAAPoA+gD6APo", + "do_not_disturb": true, + "remote_switch": true, + "cycle_timing": "AAAA", + "random_timing": "AAAA" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json b/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json new file mode 100644 index 00000000000..8e54b45ee68 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_AqHUMdcbYzIq1Of4.json @@ -0,0 +1,508 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Landing", + "category": "dj", + "product_id": "AqHUMdcbYzIq1Of4", + "product_name": "Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-04-28T06:04:01+00:00", + "create_time": "2023-04-28T06:04:01+00:00", + "update_time": "2023-04-28T06:04:01+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "scene_data": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_2": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "flash_scene_1": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_3": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "flash_scene_4": { + "type": "Json", + "value": { + "h": { + "min": 1, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 1, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 255, + "temp_value": 127, + "colour_data": { + "h": 1.0, + "s": 1.0, + "v": 255.0 + }, + "scene_data": { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + "flash_scene_1": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_2": { + "bright": 255, + "frequency": 128, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + }, + "flash_scene_3": { + "bright": 255, + "frequency": 80, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + } + ], + "temperature": 255 + }, + "flash_scene_4": { + "bright": 255, + "frequency": 5, + "hsv": [ + { + "h": 0.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 120.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 60.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 300.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 240.0, + "s": 255.0, + "v": 255.0 + }, + { + "h": 0.0, + "s": 0.0, + "v": 0.0 + } + ], + "temperature": 255 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json b/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json new file mode 100644 index 00000000000..1978a729b1a --- /dev/null +++ b/tests/components/tuya/fixtures/dj_amx1bgdrfab6jngb.json @@ -0,0 +1,333 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lumy Hall", + "category": "dj", + "product_id": "amx1bgdrfab6jngb", + "product_name": "A60 Clear", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-05-20T16:20:25+00:00", + "create_time": "2024-05-20T16:20:25+00:00", + "update_time": "2024-05-20T16:20:25+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json b/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json new file mode 100644 index 00000000000..a0e9027e70c --- /dev/null +++ b/tests/components/tuya/fixtures/dj_bSXSSFArVKtc4DyC.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "bedroom", + "category": "dj", + "product_id": "bSXSSFArVKtc4DyC", + "product_name": "Dimmer switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T05:43:06+00:00", + "create_time": "2023-04-28T05:43:06+00:00", + "update_time": "2023-04-28T05:43:06+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "bright_value": 11 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json b/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json new file mode 100644 index 00000000000..c5a1aefec54 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_c3nsqogqovapdpfj.json @@ -0,0 +1,348 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Arbeitszimmer led", + "category": "dj", + "product_id": "c3nsqogqovapdpfj", + "product_name": "RGBstriplight", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:33:52+00:00", + "create_time": "2025-03-15T13:33:52+00:00", + "update_time": "2025-03-15T13:33:52+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "scene", + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 2, + "scene_units": [ + { + "bright": 0, + "h": 132, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 1000 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_dbou1ap4.json b/tests/components/tuya/fixtures/dj_dbou1ap4.json new file mode 100644 index 00000000000..86f16136678 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_dbou1ap4.json @@ -0,0 +1,390 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Lumy Garage", + "category": "dj", + "product_id": "dbou1ap4", + "product_name": "atmosphere", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:19:47+00:00", + "create_time": "2025-07-19T11:19:47+00:00", + "update_time": "2025-07-19T11:19:47+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 1000, + "colour_data_v2": { + "h": 186, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 13, + "v": 0 + } + ] + }, + "countdown_1": 0, + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json b/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json new file mode 100644 index 00000000000..d02a94e0f71 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_tgewj70aowigv8fz.json @@ -0,0 +1,140 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Stairs", + "category": "dj", + "product_id": "tgewj70aowigv8fz", + "product_name": "RGBC Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T07:01:03+00:00", + "create_time": "2023-04-28T07:01:03+00:00", + "update_time": "2023-04-28T07:01:03+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "white", + "colour", + "scene", + "scene_1", + "scene_2", + "scene_3", + "scene_4" + ] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "white", + "colour", + "scene", + "scene_1", + "scene_2", + "scene_3", + "scene_4" + ] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 25, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": false, + "work_mode": "colour", + "bright_value": 71, + "colour_data": { + "h": 0.0, + "s": 0.0, + "v": 255.0 + } + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json b/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json new file mode 100644 index 00000000000..32688d06f5a --- /dev/null +++ b/tests/components/tuya/fixtures/dj_xdvitmhhmgefaeuq.json @@ -0,0 +1,510 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "druckerhell", + "category": "dj", + "product_id": "xdvitmhhmgefaeuq", + "product_name": "GU10 Smart Bulb", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:02:08+00:00", + "create_time": "2025-03-15T13:02:08+00:00", + "update_time": "2025-03-15T13:02:08+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value_v2": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "rhythm_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "sleep_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + }, + "wakeup_mode": { + "type": "Raw", + "value": { + "maxlen": "255" + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 1000, + "temp_value_v2": 0, + "colour_data_v2": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "", + "rhythm_mode": "AAAAAAA=", + "sleep_mode": "AAA=", + "wakeup_mode": "AAA=" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json b/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json new file mode 100644 index 00000000000..0b8bceb73e3 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_jdj6ccklup7btq3a.json @@ -0,0 +1,216 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Eau Chaude", + "category": "dlq", + "product_id": "jdj6ccklup7btq3a", + "product_name": "WiFi Din Rail Switch with metering", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-14T12:13:35+00:00", + "create_time": "2025-04-14T12:13:35+00:00", + "update_time": "2025-04-14T12:13:35+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 9999999, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 999999, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["ov_cr", "ov_vol", "ov_pwr", "ls_cr", "ls_vol", "ls_pow"] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "add_ele": 390, + "cur_current": 10067, + "cur_power": 24417, + "cur_voltage": 2419, + "test_bit": 1, + "voltage_coe": 15943, + "electric_coe": 12577, + "power_coe": 3125, + "electricity_coe": 2682, + "fault": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json b/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json new file mode 100644 index 00000000000..3ebbb27b349 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_r9kg2g1uhhyicycb.json @@ -0,0 +1,143 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": false, + "disabled_by": null, + "disabled_polling": false, + "name": "P1 Energia Elettrica", + "category": "dlq", + "product_id": "r9kg2g1uhhyicycb", + "product_name": "Breaker ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2022-12-10T17:33:13+00:00", + "create_time": "2022-12-10T17:33:13+00:00", + "update_time": "2022-12-10T17:33:13+00:00", + "function": { + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "total_forward_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status": { + "total_forward_energy": 2279960, + "phase_a": "CGYAPCgADPIACw==", + "phase_b": "AAAAAAAAAAAAAA==", + "phase_c": "AAAAAAAAAAAAAA==", + "fault": 0, + "switch_prepayment": false, + "energy_reset": "", + "balance_energy": 0, + "charge_energy": 0, + "switch": true, + "breaker_number": "FSE-F723C5EA0AC8B6" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json b/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json new file mode 100644 index 00000000000..78fce362d37 --- /dev/null +++ b/tests/components/tuya/fixtures/dr_pjvxl1wsyqxivsaf.json @@ -0,0 +1,185 @@ +{ + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Sunbeam Bedding", + "model": "", + "category": "dr", + "product_id": "pjvxl1wsyqxivsaf", + "product_name": "Sunbeam Bedding", + "online": true, + "sub": false, + "time_zone": "-05:00", + "active_time": "2022-11-07T00:20:52+00:00", + "create_time": "2022-11-01T00:43:45+00:00", + "update_time": "2022-11-07T00:20:52+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "preheat": { + "type": "Boolean", + "value": {} + }, + "preheat_1": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "preheat_2": { + "type": "Boolean", + "value": {} + }, + "level_1": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level_2": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + } + }, + "status_range": { + "level_1": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level_2": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "level": { + "type": "Enum", + "value": { + "range": [ + "level_1", + "level_2", + "level_3", + "level_4", + "level_5", + "level_6", + "level_7", + "level_8", + "level_9", + "level_10" + ] + } + }, + "preheat": { + "type": "Boolean", + "value": {} + }, + "preheat_2": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "preheat_1": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "level": "level_5", + "preheat": false, + "switch_1": false, + "switch_2": false, + "level_1": "level_5", + "level_2": "level_5", + "preheat_1": false, + "preheat_2": false + } +} diff --git a/tests/components/tuya/fixtures/hps_wqashyqo.json b/tests/components/tuya/fixtures/hps_wqashyqo.json new file mode 100644 index 00000000000..ad784b34aa9 --- /dev/null +++ b/tests/components/tuya/fixtures/hps_wqashyqo.json @@ -0,0 +1,41 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Soil moisture sensor #1", + "category": "hps", + "product_id": "wqashyqo", + "product_name": "Soil moisture sensor", + "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2025-06-03T10:48:45+00:00", + "create_time": "2025-06-03T10:48:45+00:00", + "update_time": "2025-06-03T10:48:45+00:00", + "function": {}, + "status_range": { + "presence_state": { + "type": "Enum", + "value": { + "range": ["none"] + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "presence_state": "none", + "humidity_value": 59 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json b/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json new file mode 100644 index 00000000000..bb6f8a8bba8 --- /dev/null +++ b/tests/components/tuya/fixtures/kg_4nqs33emdwJxpQ8O.json @@ -0,0 +1,54 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "office lights", + "category": "kg", + "product_id": "4nqs33emdwJxpQ8O", + "product_name": "SWITCH1", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-04-28T06:09:14+00:00", + "create_time": "2023-04-28T06:09:14+00:00", + "update_time": "2023-04-28T06:09:14+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kg_5ftkaulg.json b/tests/components/tuya/fixtures/kg_5ftkaulg.json new file mode 100644 index 00000000000..4b629f86375 --- /dev/null +++ b/tests/components/tuya/fixtures/kg_5ftkaulg.json @@ -0,0 +1,80 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "bathroom light", + "category": "kg", + "product_id": "5ftkaulg", + "product_name": "1Gang Zigbee Switch", + "online": true, + "sub": true, + "time_zone": "+00:00", + "active_time": "2023-07-04T15:11:35+00:00", + "create_time": "2023-07-04T15:11:35+00:00", + "update_time": "2023-07-04T15:11:35+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["none", "relay", "pos"] + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0, + "relay_status": "power_off", + "light_mode": "pos" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json b/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json new file mode 100644 index 00000000000..b4ae9a35391 --- /dev/null +++ b/tests/components/tuya/fixtures/kj_s4uzibibgzdxzowo.json @@ -0,0 +1,115 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "ION1000PRO", + "category": "kj", + "product_id": "s4uzibibgzdxzowo", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+08:00", + "active_time": "2023-12-21T05:50:50+00:00", + "create_time": "2023-12-21T05:50:50+00:00", + "update_time": "2023-12-21T05:50:50+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "pm25": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "lock": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "filter_days": { + "type": "Integer", + "value": { + "unit": "Hours", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 720, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": true, + "pm25": 9, + "anion": false, + "lock": true, + "uv": true, + "filter_reset": false, + "filter_days": 0, + "countdown_set": "cancle", + "countdown_left": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_6ywsnauy.json b/tests/components/tuya/fixtures/mcs_6ywsnauy.json new file mode 100644 index 00000000000..8612a42c0c6 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_6ywsnauy.json @@ -0,0 +1,44 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Fen\u00eatre cuisine", + "category": "mcs", + "product_id": "6ywsnauy", + "product_name": "Contact Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:54:49+00:00", + "create_time": "2025-07-19T11:54:49+00:00", + "update_time": "2025-07-19T11:54:49+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temper_alarm": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 93, + "temper_alarm": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_8yhypbo7.json b/tests/components/tuya/fixtures/mcs_8yhypbo7.json new file mode 100644 index 00000000000..ee5e125acd5 --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_8yhypbo7.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bo\u00eete aux lettres - arri\u00e8re", + "category": "mcs", + "product_id": "8yhypbo7", + "product_name": "Door Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:44:37+00:00", + "create_time": "2025-07-19T11:44:37+00:00", + "update_time": "2025-07-19T11:44:37+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 62 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json b/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json new file mode 100644 index 00000000000..b0011708edf --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_hx5ztlztij4yxxvg.json @@ -0,0 +1,35 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "name": "Steel cage door", + "category": "mcs", + "product_id": "hx5ztlztij4yxxvg", + "product_name": "Door Detector", + "online": true, + "sub": false, + "time_zone": "-05:00", + "active_time": "2020-05-28T22:07:06+00:00", + "create_time": "2020-05-28T22:07:06+00:00", + "update_time": "2020-05-28T22:07:06+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "doorcontact_state": false, + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json b/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json new file mode 100644 index 00000000000..708de40152f --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_qxu3flpqjsc1kqu3.json @@ -0,0 +1,39 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Contact Sensor", + "category": "mcs", + "product_id": "qxu3flpqjsc1kqu3", + "product_name": "Contact Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-03-11T23:28:59+00:00", + "create_time": "2023-03-11T23:28:59+00:00", + "update_time": "2023-03-11T23:28:59+00:00", + "function": {}, + "status_range": { + "doorcontact_state": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "doorcontact_state": false, + "battery_percentage": 11 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json b/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json new file mode 100644 index 00000000000..0bc304817b4 --- /dev/null +++ b/tests/components/tuya/fixtures/ntq_9mqdhwklpvnnvb7t.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "\u0411\u0440\u0438\u0437\u0435\u0440 \u0417\u0430\u043b", + "category": "ntq", + "product_id": "9mqdhwklpvnnvb7t", + "product_name": "TION Breezer Bio X", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2024-08-16T14:49:45+00:00", + "create_time": "2024-08-16T14:49:45+00:00", + "update_time": "2024-08-16T14:49:45+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json b/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json new file mode 100644 index 00000000000..045b6383f72 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_tsbguim4trl6fa7g.json @@ -0,0 +1,188 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Keller", + "category": "pc", + "product_id": "tsbguim4trl6fa7g", + "product_name": "Smart Power Strip", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-18T14:24:59+00:00", + "create_time": "2025-03-18T14:24:59+00:00", + "update_time": "2025-03-18T14:24:59+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_usb1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_usb1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_usb1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_usb1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 50000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "switch_2": true, + "switch_3": false, + "switch_usb1": false, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_usb1": 0, + "add_ele": 2, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json b/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json new file mode 100644 index 00000000000..2d843bdf058 --- /dev/null +++ b/tests/components/tuya/fixtures/pc_yku9wsimasckdt15.json @@ -0,0 +1,189 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Framboisier", + "category": "pc", + "product_id": "yku9wsimasckdt15", + "product_name": "Konyks Priska USB", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-19T09:45:39+00:00", + "create_time": "2025-07-19T09:45:39+00:00", + "update_time": "2025-07-19T09:45:39+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 30000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 80000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": {} + }, + "random_time": { + "type": "String", + "value": {} + }, + "switch_inching": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_1": true, + "switch_2": true, + "countdown_1": 0, + "countdown_2": 0, + "add_ele": 1, + "cur_current": 0, + "cur_power": 0, + "cur_voltage": 2471, + "relay_status": "power_on", + "light_mode": "none", + "child_lock": false, + "cycle_time": "", + "random_time": "", + "switch_inching": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json b/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json new file mode 100644 index 00000000000..15aab08ab4a --- /dev/null +++ b/tests/components/tuya/fixtures/sd_i6hyjg3af7doaswm.json @@ -0,0 +1,64 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Hoover", + "category": "sd", + "product_id": "i6hyjg3af7doaswm", + "product_name": "E20", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-05-05T15:25:07+00:00", + "create_time": "2023-05-05T15:25:07+00:00", + "update_time": "2023-05-05T15:25:07+00:00", + "function": { + "power": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["random", "smart", "wall_follow", "chargego"] + } + }, + "power_go": { + "type": "Boolean", + "value": {} + }, + "seek": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "power": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["random", "smart", "wall_follow", "chargego"] + } + }, + "power_go": { + "type": "Boolean", + "value": {} + }, + "seek": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "power": true, + "mode": "chargego", + "power_go": false, + "seek": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json b/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json new file mode 100644 index 00000000000..c9ccae70d21 --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_1fcnd8xk.json @@ -0,0 +1,130 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Valve Controller 2", + "category": "sfkzq", + "product_id": "1fcnd8xk", + "product_name": "Valve Controller", + "online": false, + "sub": true, + "time_zone": "+01:00", + "active_time": "2023-07-16T09:37:13+00:00", + "create_time": "2023-07-16T09:37:13+00:00", + "update_time": "2023-07-16T09:37:13+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "time_use": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 2592000, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "smart_weather": { + "type": "Enum", + "value": { + "range": ["sunny", "cloudy", "rainy"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "time_use": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 2592000, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "idle"] + } + }, + "smart_weather": { + "type": "Enum", + "value": { + "range": ["sunny", "cloudy", "rainy"] + } + }, + "use_time_one": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "battery_percentage": 100, + "time_use": 14025, + "weather_delay": "cancel", + "countdown": 0, + "work_state": "idle", + "smart_weather": "sunny", + "use_time_one": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json b/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json new file mode 100644 index 00000000000..2cfcf00cd53 --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_rzklytdei8i8vo37.json @@ -0,0 +1,100 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "balkonbew\u00e4sserung", + "category": "sfkzq", + "product_id": "rzklytdei8i8vo37", + "product_name": "Smart Water Timer", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-29T17:45:56+00:00", + "create_time": "2025-04-29T17:45:56+00:00", + "update_time": "2025-04-29T17:45:56+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "weather_delay": { + "type": "Enum", + "value": { + "range": ["cancel", "24h", "48h", "72h"] + } + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "work_state": { + "type": "Enum", + "value": { + "range": ["auto", "manual", "idle"] + } + }, + "use_time_one": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "battery_percentage": 90, + "weather_delay": "cancel", + "countdown": 0, + "work_state": "idle", + "use_time_one": 52 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json b/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json new file mode 100644 index 00000000000..21d6b7db1d1 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_nzauwyj3mcnjnf35.json @@ -0,0 +1,220 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Garage Camera", + "category": "sp", + "product_id": "nzauwyj3mcnjnf35", + "product_name": "Smart Camera ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-01-14T11:37:22+00:00", + "create_time": "2024-01-14T11:37:22+00:00", + "update_time": "2024-01-14T11:37:22+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "sd_umount": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "motion_area_switch": { + "type": "Boolean", + "value": {} + }, + "motion_area": { + "type": "String", + "value": { + "maxlen": 255 + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "min": 1, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "sd_umount": { + "type": "Boolean", + "value": {} + }, + "motion_record": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "min": -20000, + "max": 200000, + "scale": 0, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "motion_area_switch": { + "type": "Boolean", + "value": {} + }, + "motion_area": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "alarm_message": { + "type": "String", + "value": {} + } + }, + "status": { + "basic_flip": true, + "basic_osd": true, + "basic_private": false, + "motion_sensitivity": 0, + "sd_storge": "896|896|0", + "sd_status": 5, + "sd_format": false, + "sd_umount": false, + "motion_record": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": true, + "sd_format_state": 0, + "ptz_control": 3, + "motion_switch": false, + "record_switch": true, + "record_mode": 1, + "motion_tracking": false, + "motion_area_switch": true, + "motion_area": { + "num": 1, + "region0": { + "x": 0, + "y": 0, + "xlen": 100, + "ylen": 100 + } + }, + "alarm_message": "**REDACTED**" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json b/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json new file mode 100644 index 00000000000..06d3f0a2705 --- /dev/null +++ b/tests/components/tuya/fixtures/sp_rudejjigkywujjvs.json @@ -0,0 +1,240 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "B\u00fcrocam", + "category": "sp", + "product_id": "rudejjigkywujjvs", + "product_name": "LSC PTZ Camera", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:19:09+00:00", + "create_time": "2025-03-15T13:19:09+00:00", + "update_time": "2025-03-15T13:19:09+00:00", + "function": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + } + }, + "status_range": { + "basic_flip": { + "type": "Boolean", + "value": {} + }, + "basic_osd": { + "type": "Boolean", + "value": {} + }, + "basic_private": { + "type": "Boolean", + "value": {} + }, + "motion_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "basic_nightvision": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "sd_storge": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "sd_status": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 5, + "scale": 1, + "step": 1 + } + }, + "sd_format": { + "type": "Boolean", + "value": {} + }, + "movement_detect_pic": { + "type": "Raw", + "value": {} + }, + "ptz_stop": { + "type": "Boolean", + "value": {} + }, + "sd_format_state": { + "type": "Integer", + "value": { + "unit": "", + "min": -20000, + "max": 20000, + "scale": 1, + "step": 1 + } + }, + "ptz_control": { + "type": "Enum", + "value": { + "range": ["1", "2", "3", "4", "5", "6", "7", "0"] + } + }, + "ptz_calibration": { + "type": "Boolean", + "value": {} + }, + "motion_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_switch": { + "type": "Boolean", + "value": {} + }, + "decibel_sensitivity": { + "type": "Enum", + "value": { + "range": ["0", "1"] + } + }, + "record_switch": { + "type": "Boolean", + "value": {} + }, + "record_mode": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + }, + "siren_switch": { + "type": "Boolean", + "value": {} + }, + "motion_tracking": { + "type": "Boolean", + "value": {} + }, + "alarm_message": { + "type": "String", + "value": {} + }, + "basic_anti_flicker": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + } + }, + "status": { + "basic_flip": false, + "basic_osd": true, + "basic_private": true, + "motion_sensitivity": 1, + "basic_nightvision": 0, + "sd_storge": "30956544|30674944|281600", + "sd_status": 1, + "sd_format": false, + "movement_detect_pic": "**REDACTED**", + "ptz_stop": false, + "sd_format_state": 0, + "ptz_control": 1, + "ptz_calibration": false, + "motion_switch": true, + "decibel_switch": false, + "decibel_sensitivity": 0, + "record_switch": true, + "record_mode": 1, + "siren_switch": false, + "motion_tracking": false, + "alarm_message": "**REDACTED**", + "basic_anti_flicker": 1 + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json b/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json new file mode 100644 index 00000000000..d30da9ff29b --- /dev/null +++ b/tests/components/tuya/fixtures/wfcon_lieerjyy6l4ykjor.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Zigbee Gateway", + "category": "wfcon", + "product_id": "lieerjyy6l4ykjor", + "product_name": "Zigbee Smart Gateway", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-09-10T17:09:13+00:00", + "create_time": "2023-09-10T17:09:13+00:00", + "update_time": "2023-09-10T17:09:13+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json b/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json new file mode 100644 index 00000000000..78afaebc51f --- /dev/null +++ b/tests/components/tuya/fixtures/wkcz_gc4b1mdw7kebtuyz.json @@ -0,0 +1,227 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "pid_relay_2", + "category": "wkcz", + "product_id": "gc4b1mdw7kebtuyz", + "product_name": "4-TH", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-14T16:04:37+00:00", + "create_time": "2025-01-14T16:04:37+00:00", + "update_time": "2025-01-14T16:04:37+00:00", + "function": { + "switch_all": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -90, + "max": 90, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "hum_calibration": { + "type": "Integer", + "value": { + "unit": "%", + "min": -10, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_all": { + "type": "Boolean", + "value": {} + }, + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -500, + "max": 1100, + "scale": 1, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "", + "min": -90, + "max": 90, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["cooling_fault", "heating_fault", "temp_dif_fault"] + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "hum_calibration": { + "type": "Integer", + "value": { + "unit": "%", + "min": -10, + "max": 10, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_all": false, + "switch_1": false, + "switch_2": false, + "temp_current": 170, + "upper_temp": 0, + "lower_temp": 0, + "countdown_1": 0, + "temp_correction": 0, + "fault": 0, + "humidity_value": 38, + "maxhum_set": 0, + "minihum_set": 0, + "hum_calibration": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json b/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json new file mode 100644 index 00000000000..2ea3099bc2b --- /dev/null +++ b/tests/components/tuya/fixtures/wnykq_npbbca46yiug8ysk.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bedroom IR", + "category": "wnykq", + "product_id": "npbbca46yiug8ysk", + "product_name": "Smart IR", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-10T17:32:31+00:00", + "create_time": "2023-08-10T17:32:31+00:00", + "update_time": "2023-08-10T17:32:31+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json b/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json new file mode 100644 index 00000000000..f2ceac8e898 --- /dev/null +++ b/tests/components/tuya/fixtures/wnykq_rqhxdyusjrwxyff6.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Smart IR", + "category": "wnykq", + "product_id": "rqhxdyusjrwxyff6", + "product_name": "Smart IR", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:07:37+00:00", + "create_time": "2024-07-18T12:07:37+00:00", + "update_time": "2024-07-18T12:07:37+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json b/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json new file mode 100644 index 00000000000..d76dae842fa --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_iq4ygaai.json @@ -0,0 +1,45 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bassin", + "category": "wsdcg", + "product_id": "iq4ygaai", + "product_name": "Temperature Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:20:32+00:00", + "create_time": "2025-07-19T12:20:32+00:00", + "update_time": "2025-07-19T12:20:32+00:00", + "function": {}, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 1990, + "scale": 1, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 217, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json b/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json new file mode 100644 index 00000000000..b96cb26a1a9 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_iv7hudlj.json @@ -0,0 +1,168 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Basement temperature", + "category": "wsdcg", + "product_id": "iv7hudlj", + "product_name": "Bluetooth Temperature Humidity Sensor", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-10-13T10:07:37+00:00", + "create_time": "2024-10-13T10:07:37+00:00", + "update_time": "2024-10-13T10:07:37+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + } + }, + "status": { + "va_temperature": 162, + "va_humidity": 47, + "battery_percentage": 100, + "temp_unit_convert": "c", + "maxtemp_set": 400, + "minitemp_set": 200, + "maxhum_set": 85, + "minihum_set": 20, + "temp_alarm": "loweralarm", + "hum_alarm": "cancel" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json b/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json new file mode 100644 index 00000000000..023bcc269fa --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_krlcihrpzpc8olw9.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "IFS-STD002", + "category": "wsdcg", + "product_id": "krlcihrpzpc8olw9", + "product_name": "IFS-STD002", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-06-28T13:33:51+00:00", + "create_time": "2025-06-28T13:33:51+00:00", + "update_time": "2025-06-28T13:33:51+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "va_temperature": 289, + "va_humidity": 61, + "battery_state": "high", + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json b/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json new file mode 100644 index 00000000000..5c52cf2796e --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_lf36y5nwb8jkxwgg.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Greenhouse", + "category": "wsdcg", + "product_id": "lf36y5nwb8jkxwgg", + "product_name": "T & H Sensor", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-08-14T13:59:25+00:00", + "create_time": "2023-08-14T13:59:25+00:00", + "update_time": "2023-08-14T13:59:25+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 99, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "va_temperature": 322, + "va_humidity": 53, + "battery_state": "middle", + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json b/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json new file mode 100644 index 00000000000..1eb84adfc31 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_vtA4pDd6PLUZzXgZ.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Humy bain", + "category": "wsdcg", + "product_id": "vtA4pDd6PLUZzXgZ", + "product_name": "Temperature and humidity sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T12:04:21+00:00", + "create_time": "2025-07-19T12:04:21+00:00", + "update_time": "2025-07-19T12:04:21+00:00", + "function": {}, + "status_range": { + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "va_battery": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_humidity": 63, + "va_battery": 100, + "va_temperature": 20 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_xr3htd96.json b/tests/components/tuya/fixtures/wsdcg_xr3htd96.json new file mode 100644 index 00000000000..13bdff60b33 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_xr3htd96.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Humy toilettes RDC", + "category": "wsdcg", + "product_id": "xr3htd96", + "product_name": "Temperature Humidity Sensor", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-08-01T09:33:45+00:00", + "create_time": "2025-08-01T09:33:45+00:00", + "update_time": "2025-08-01T09:33:45+00:00", + "function": {}, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 206, + "va_humidity": 618, + "battery_percentage": 100 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json b/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json new file mode 100644 index 00000000000..1186bfb4572 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_yqiqbaldtr0i7mru.json @@ -0,0 +1,259 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": false, + "disabled_by": null, + "disabled_polling": false, + "name": "WiFi Temperature & Humidity Sensor", + "category": "wsdcg", + "product_id": "yqiqbaldtr0i7mru", + "product_name": "WiFi Temperature & Humidity Sensor", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2023-11-27T03:59:48+00:00", + "create_time": "2023-11-27T03:59:48+00:00", + "update_time": "2023-11-27T03:59:48+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "hum_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 20, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 20, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "temp_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "hum_periodic_report": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 120, + "scale": 0, + "step": 1 + } + }, + "temp_sensitivity": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 3, + "max": 20, + "scale": 1, + "step": 1 + } + }, + "hum_sensitivity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 3, + "max": 20, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "va_temperature": 251, + "va_humidity": 12, + "battery_state": "high", + "battery_percentage": 100, + "temp_unit_convert": "c", + "maxtemp_set": 390, + "minitemp_set": 0, + "maxhum_set": 60, + "minihum_set": 20, + "temp_alarm": "cancel", + "hum_alarm": "loweralarm", + "temp_periodic_report": 60, + "hum_periodic_report": 120, + "temp_sensitivity": 6, + "hum_sensitivity": 6 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/wxkg_ja5osu5g.json b/tests/components/tuya/fixtures/wxkg_ja5osu5g.json new file mode 100644 index 00000000000..d8e841fc599 --- /dev/null +++ b/tests/components/tuya/fixtures/wxkg_ja5osu5g.json @@ -0,0 +1,64 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bouton tempo ext\u00e9rieur", + "category": "wxkg", + "product_id": "ja5osu5g", + "product_name": "ZC-YED-\u4e00\u952e\u65e0\u7ebf\u5f00\u5173", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T15:49:27+00:00", + "create_time": "2025-07-25T15:49:27+00:00", + "update_time": "2025-07-25T15:49:27+00:00", + "function": { + "mode": { + "type": "Enum", + "value": { + "range": ["remote_control", "wireless_switch"] + } + }, + "scene_preset": { + "type": "String", + "value": {} + } + }, + "status_range": { + "switch_mode1": { + "type": "Enum", + "value": { + "range": ["click", "double_click", "press"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["remote_control", "wireless_switch"] + } + }, + "scene_preset": { + "type": "String", + "value": {} + } + }, + "status": { + "switch_mode1": "press", + "battery_percentage": 100, + "mode": "wireless_switch", + "scene_preset": "800003e80384005a03e803e8810003e8006400b403e803e8820001900320010e03e803e883010190000000f000fa00fa" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json b/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json new file mode 100644 index 00000000000..1f517f9b775 --- /dev/null +++ b/tests/components/tuya/fixtures/ygsb_l6ax0u6jwbz82atk.json @@ -0,0 +1,63 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Pond", + "category": "ygsb", + "product_id": "l6ax0u6jwbz82atk", + "product_name": "\u6c34\u6cf5", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-07T22:22:44+00:00", + "create_time": "2025-06-07T22:22:44+00:00", + "update_time": "2025-06-07T22:22:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_flow": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "pause": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_flow": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "pause": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "water_flow": 0, + "pause": true + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ykq_bngwdjsr.json b/tests/components/tuya/fixtures/ykq_bngwdjsr.json new file mode 100644 index 00000000000..085dd52b6cb --- /dev/null +++ b/tests/components/tuya/fixtures/ykq_bngwdjsr.json @@ -0,0 +1,70 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "T\u00e9l\u00e9commande lumi\u00e8res ZigBee", + "category": "ykq", + "product_id": "bngwdjsr", + "product_name": "Remote controller", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-19T11:36:45+00:00", + "create_time": "2025-07-19T11:36:45+00:00", + "update_time": "2025-07-19T11:36:45+00:00", + "function": { + "switch_controller": { + "type": "Boolean", + "value": {} + }, + "mode_controller": { + "type": "Enum", + "value": { + "range": ["white", "colour"] + } + }, + "bright_controller": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_controller": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "scene_controller": { + "type": "Enum", + "value": { + "range": ["scene_1", "scene_2", "scene_3", "scene_4"] + } + } + }, + "status": { + "battery_percentage": 100, + "scene_controller": "scene_1" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json b/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json new file mode 100644 index 00000000000..eee71d0c45a --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_arywmw6h6vesoz5t.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rauchmelder Drucker", + "category": "ywbj", + "product_id": "arywmw6h6vesoz5t", + "product_name": "Smoke Alarm ", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-03-15T13:45:57+00:00", + "create_time": "2025-03-15T13:45:57+00:00", + "update_time": "2025-03-15T13:45:57+00:00", + "function": {}, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json b/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json new file mode 100644 index 00000000000..6495e99e4d3 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_cjlutkuuvxnie17o.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Rauchmelder Alexsandro ", + "category": "ywbj", + "product_id": "cjlutkuuvxnie17o", + "product_name": "Smoke Alarm", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-01-07T18:50:02+00:00", + "create_time": "2023-01-07T18:50:02+00:00", + "update_time": "2023-01-07T18:50:02+00:00", + "function": {}, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_state": "high" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json b/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json new file mode 100644 index 00000000000..00e5db9dc94 --- /dev/null +++ b/tests/components/tuya/fixtures/ywbj_kscbebaf3s1eogvt.json @@ -0,0 +1,51 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "WIFI Smoke alarm", + "category": "ywbj", + "product_id": "kscbebaf3s1eogvt", + "product_name": "WIFI Smoke alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2023-08-16T13:20:39+00:00", + "create_time": "2023-08-16T13:20:39+00:00", + "update_time": "2023-08-16T13:20:39+00:00", + "function": { + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "smoke_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "smoke_sensor_status": "normal", + "battery_percentage": 90, + "muffling": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zjq_nkkl7uzv.json b/tests/components/tuya/fixtures/zjq_nkkl7uzv.json new file mode 100644 index 00000000000..043db64dc77 --- /dev/null +++ b/tests/components/tuya/fixtures/zjq_nkkl7uzv.json @@ -0,0 +1,21 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Zigby r\u00e9p\u00e9teur ", + "category": "zjq", + "product_id": "nkkl7uzv", + "product_name": "Zigbee Repeater", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-07-25T17:19:41+00:00", + "create_time": "2025-07-25T17:19:41+00:00", + "update_time": "2025-07-25T17:19:41+00:00", + "function": {}, + "status_range": {}, + "status": {}, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json b/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json new file mode 100644 index 00000000000..01401e16dd3 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_v5jlnn5hwyffkhp3.json @@ -0,0 +1,77 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Production", + "category": "zndb", + "product_id": "v5jlnn5hwyffkhp3", + "product_name": "Smart Meter", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-04-18T08:57:59+00:00", + "create_time": "2025-04-18T08:57:59+00:00", + "update_time": "2025-04-18T08:57:59+00:00", + "function": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "total_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": -99999999, + "max": 99999999, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "forward_energy_total": 152021, + "reverse_energy_total": 0, + "total_power": 23146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json b/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json new file mode 100644 index 00000000000..6e379eff375 --- /dev/null +++ b/tests/components/tuya/fixtures/znrb_db81ge24jctwx8lo.json @@ -0,0 +1,172 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Hot Water Heat Pump", + "category": "znrb", + "product_id": "db81ge24jctwx8lo", + "product_name": "Heat Pump", + "online": true, + "sub": false, + "time_zone": "+11:00", + "active_time": "2025-01-08T23:48:22+00:00", + "create_time": "2025-01-08T23:48:22+00:00", + "update_time": "2025-01-08T23:48:22+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 15, + "max": 75, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "defrost": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 15, + "max": 75, + "scale": 0, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "defrost": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "P", + "min": -500, + "max": 500, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -30, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "power_consumption": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 100000, + "scale": 2, + "step": 1 + } + }, + "compressor_strength": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -50, + "max": 140, + "scale": 0, + "step": 1 + } + }, + "temp_top": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -58, + "max": 312, + "scale": 0, + "step": 1 + } + }, + "temp_bottom": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -50, + "max": 150, + "scale": 0, + "step": 1 + } + }, + "compressor_state": { + "type": "Boolean", + "value": {} + }, + "four_valve_state": { + "type": "Boolean", + "value": {} + }, + "draught_fan_state": { + "type": "Boolean", + "value": {} + }, + "pump_state": { + "type": "Boolean", + "value": {} + }, + "ele_heating_state": { + "type": "Boolean", + "value": {} + }, + "defrost_state": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "temp_set": 60, + "temp_unit_convert": "c", + "defrost": false, + "countdown_left": 300, + "temp_current": 42, + "power_consumption": 0, + "compressor_strength": 23, + "temp_top": 21, + "temp_bottom": -50, + "compressor_state": false, + "four_valve_state": false, + "draught_fan_state": false, + "pump_state": true, + "ele_heating_state": false, + "defrost_state": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 26bfd9e0d42..cdc009a5d78 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -48,6 +48,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.boite_aux_lettres_arriere_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.boite_aux_lettres_arriere_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.7obpyhy8scmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.boite_aux_lettres_arriere_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Boîte aux lettres - arrière Door', + }), + 'context': , + 'entity_id': 'binary_sensor.boite_aux_lettres_arriere_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.dehumidifer_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -342,6 +391,153 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_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.fenetre_cuisine_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yuanswy6scmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Fenêtre cuisine Door', + }), + 'context': , + 'entity_id': 'binary_sensor.fenetre_cuisine_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_tamper-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.fenetre_cuisine_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.yuanswy6scmtemper_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.fenetre_cuisine_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Fenêtre cuisine Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.fenetre_cuisine_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.garage_contact_sensor_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.garage_contact_sensor_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.3uqk1csjqplf3uxqscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.garage_contact_sensor_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Garage Contact Sensor Door', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_contact_sensor_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -783,6 +979,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_alexsandro_smoke-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.rauchmelder_alexsandro_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.o71einxvuuktuljcjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_alexsandro_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Rauchmelder Alexsandro Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.rauchmelder_alexsandro_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_drucker_smoke-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.rauchmelder_drucker_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.t5zosev6h6wmwyrajbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.rauchmelder_drucker_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Rauchmelder Drucker Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.rauchmelder_drucker_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.smart_thermostats_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -831,6 +1125,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.smogo_safety-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.smogo_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocco_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.smogo_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'Smogo Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.smogo_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.smoke_detector_upstairs_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -880,6 +1223,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.soil_moisture_sensor_1_occupancy-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.soil_moisture_sensor_1_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.oqyhsaqwsphpresence_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.soil_moisture_sensor_1_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'occupancy', + 'friendly_name': 'Soil moisture sensor #1 Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.soil_moisture_sensor_1_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.steel_cage_door_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.steel_cage_door_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.gvxxy4jitzltz5xhscmdoorcontact_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.steel_cage_door_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Steel cage door Door', + }), + 'context': , + 'entity_id': 'binary_sensor.steel_cage_door_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.tournesol_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -929,6 +1370,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[binary_sensor.wifi_smoke_alarm_smoke-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.wifi_smoke_alarm_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.tvgoe1s3fabebcskjbwysmoke_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[binary_sensor.wifi_smoke_alarm_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'WIFI Smoke alarm Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.wifi_smoke_alarm_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[binary_sensor.x5_zigbee_gateway_problem-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_camera.ambr b/tests/components/tuya/snapshots/test_camera.ambr index b1ec2191850..df6ea532d83 100644 --- a/tests/components/tuya/snapshots/test_camera.ambr +++ b/tests/components/tuya/snapshots/test_camera.ambr @@ -1,4 +1,58 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[camera.burocam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.burocam', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.svjjuwykgijjedurps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.burocam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.burocam?token=1', + 'friendly_name': 'Bürocam', + 'model_name': 'LSC PTZ Camera', + 'motion_detection': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.burocam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- # name: test_platform_setup_and_discovery[camera.c9-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -160,3 +214,56 @@ 'state': 'idle', }) # --- +# name: test_platform_setup_and_discovery[camera.garage_camera-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.garage_camera', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.53fnjncm3jywuaznps', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[camera.garage_camera-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'brand': 'Tuya', + 'entity_picture': '/api/camera_proxy/camera.garage_camera?token=1', + 'friendly_name': 'Garage Camera', + 'model_name': 'Smart Camera ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.garage_camera', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'recording', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr index 8e2afbdb9de..ce7c1cf67de 100644 --- a/tests/components/tuya/snapshots/test_event.ambr +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -117,3 +117,64 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'double_click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bouton_tempo_exterieur_button_1', + '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 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.g5uso5ajgkxwswitch_mode1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'double_click', + 'press', + ]), + 'friendly_name': 'Bouton tempo extérieur Button 1', + }), + 'context': , + 'entity_id': 'event.bouton_tempo_exterieur_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index f3088b51d45..1474aa7cccd 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -381,6 +381,56 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[fan.ion1000pro-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ion1000pro', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.owozxdzgbibizu4sjk', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.ion1000pro-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.ion1000pro', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[fan.kalado_air_purifier-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index d4dcd12cbb3..40c2b7451d2 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -148,6 +148,79 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.arbeitszimmer_led-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.arbeitszimmer_led', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.jfpdpavoqgoqsn3cjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.arbeitszimmer_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'friendly_name': 'Arbeitszimmer led', + 'hs_color': tuple( + 0.0, + 100.0, + ), + 'rgb_color': tuple( + 255, + 0, + 0, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.701, + 0.299, + ), + }), + 'context': , + 'entity_id': 'light.arbeitszimmer_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.b2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -214,6 +287,64 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.bedroom-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.bedroom', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.CyD4ctKVrAFSSXSbjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'bedroom', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.cam_garage_indicator_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -697,6 +828,89 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[light.druckerhell-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.druckerhell', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.queafegmhhmtivdxjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.druckerhell-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 500, + 'color_temp_kelvin': 2000, + 'friendly_name': 'druckerhell', + 'hs_color': tuple( + 30.601, + 94.547, + ), + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 137, + 14, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.598, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.druckerhell', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[light.erker_1_gold-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1415,6 +1629,79 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[light.landing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.landing', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.4fO1qIzYbcdMUHqAjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.landing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Landing', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.landing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.led_keuken_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1630,6 +1917,150 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.lumy_garage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.lumy_garage', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.4pa1uobdjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_garage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lumy Garage', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.lumy_garage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.lumy_hall', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bgnj6bafrdgb1xmajdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.lumy_hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lumy Hall', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.lumy_hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.master_bedroom_tv_lights-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2095,6 +2526,69 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.stairs-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.stairs', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.zf8vgiwoa07jwegtjdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.stairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Stairs', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.stairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.stoel-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2225,6 +2719,79 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[light.study_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + '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.study_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.sifg4pfqsylsayg0jdswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[light.study_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Study 1', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.study_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[light.tapparelle_studio_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 7ab05e49463..adfc7543a3e 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -174,6 +174,65 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[number.hot_water_heat_pump_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75.0, + 'min': 15.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.hot_water_heat_pump_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnztemp_set', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[number.hot_water_heat_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hot Water Heat Pump Temperature', + 'max': 75.0, + 'min': 15.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.hot_water_heat_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- # name: test_platform_setup_and_discovery[number.house_water_level_alarm_maximum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 9acc761f805..4e701b39566 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,122 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[select.3dprinter_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.3dprinter_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pykascx9yfqrxtbgzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.3dprinter_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.3dprinter_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.3dprinter_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.3dprinter_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- # name: test_platform_setup_and_discovery[select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -119,6 +237,421 @@ 'state': 'low', }) # --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_cooker_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.cju47ovcbeuapei2zclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_cooker_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_cooker_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.cju47ovcbeuapei2zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_cooker_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_cooker_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_washing_machine_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_washing_machine_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aubess_washing_machine_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.aubess_washing_machine_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.aubess_washing_machine_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.balkonbewasserung_weather_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.balkonbewasserung_weather_delay', + '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': 'Weather delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_delay', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsweather_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.balkonbewasserung_weather_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'balkonbewässerung Weather delay', + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'context': , + 'entity_id': 'select.balkonbewasserung_weather_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bathroom_light_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.gluaktf5gklight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'bathroom light Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_light_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pos', + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bathroom_light_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.gluaktf5gkrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.bathroom_light_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'bathroom light Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_light_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_off', + }) +# --- # name: test_platform_setup_and_discovery[select.blinds_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -241,6 +774,297 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[select.burocam_anti_flicker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_anti_flicker', + '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': 'Anti-flicker', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_anti_flicker', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_anti_flicker', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_anti_flicker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Anti-flicker', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_anti_flicker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_night_vision-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_night_vision', + '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': 'Night vision', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'basic_nightvision', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_nightvision', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_night_vision-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Night vision', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_night_vision', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.svjjuwykgijjedurpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_sound_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.burocam_sound_detection_sensitivity', + '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': 'Sound detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'decibel_sensitivity', + 'unique_id': 'tuya.svjjuwykgijjedurpsdecibel_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.burocam_sound_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Sound detection sensitivity', + 'options': list([ + '0', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.burocam_sound_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[select.c9_ipc_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1004,6 +1828,360 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_kitchen_socket_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_kitchen_socket_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_kitchen_socket_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_kitchen_socket_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_kitchen_socket_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_tv_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_tv_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.elivco_tv_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.elivco_tv_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.elivco_tv_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisier_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.51tdkcsamisw9ukycplight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.framboisier_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.framboisier_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.51tdkcsamisw9ukycprelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.framboisier_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.framboisier_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- # name: test_platform_setup_and_discovery[select.framboisiers_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1063,6 +2241,307 @@ 'state': 'unknown', }) # --- +# name: test_platform_setup_and_discovery[select.garage_camera_motion_detection_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_camera_motion_detection_sensitivity', + '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': 'Motion detection sensitivity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_motion_detection_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion detection sensitivity', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.garage_camera_motion_detection_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_record_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_camera_record_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': 'Record mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_mode', + 'unique_id': 'tuya.53fnjncm3jywuaznpsrecord_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.garage_camera_record_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Record mode', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.garage_camera_record_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.hoover_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'random', + 'smart', + 'wall_follow', + 'chargego', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.hoover_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_mode', + 'unique_id': 'tuya.mwsaod7fa3gjyh6idsmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.hoover_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hoover Mode', + 'options': list([ + 'random', + 'smart', + 'wall_follow', + 'chargego', + ]), + }), + 'context': , + 'entity_id': 'select.hoover_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'chargego', + }) +# --- +# name: test_platform_setup_and_discovery[select.ineox_sp2_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ineox_sp2_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ineox_sp2_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ineox SP2 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.ineox_sp2_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.ion1000pro_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ion1000pro_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.owozxdzgbibizu4sjkcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.ion1000pro_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Countdown', + 'options': list([ + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'select.ion1000pro_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[select.jardin_fraises_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1244,6 +2723,478 @@ 'state': 'forward', }) # --- +# name: test_platform_setup_and_discovery[select.lave_linge_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lave_linge_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.g0edqq0wzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.lave_linge_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lave_linge_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.g0edqq0wzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.lave_linge_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.lave_linge_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.office_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.office_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.2x473nefusdo7af6zclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.office_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.office_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.office_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.office_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.2x473nefusdo7af6zcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.office_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.office_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.raspy4_home_assistant_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.zaszonjgzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.raspy4_home_assistant_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.raspy4_home_assistant_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.zaszonjgzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.raspy4_home_assistant_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.raspy4_home_assistant_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_light_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Indicator light mode', + 'options': list([ + 'none', + 'relay', + 'pos', + ]), + }), + 'context': , + 'entity_id': 'select.security_light_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.security_light_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.security_light_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.security_light_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'power_on', + }) +# --- # name: test_platform_setup_and_discovery[select.siren_veranda_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1421,6 +3372,464 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.socket4_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket4_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.socket4_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.socket4_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.socket4_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.socket4_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.spot_1_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.kffnst1epj6vr8xnzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.spot_1_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'relay', + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.spot_1_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.spot_1_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.spot_1_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'last', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Side A Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_a_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Side A Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_side_a_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer-lines', + 'original_name': 'Side B Level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'blanket_level', + 'unique_id': 'tuya.fasvixqysw1lxvjprdlevel_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.sunbeam_bedding_side_b_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sunbeam Bedding Side B Level', + 'icon': 'mdi:thermometer-lines', + 'options': list([ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', + 'level_6', + 'level_7', + 'level_8', + 'level_9', + 'level_10', + ]), + }), + 'context': , + 'entity_id': 'select.sunbeam_bedding_side_b_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_5', + }) +# --- # name: test_platform_setup_and_discovery[select.v20_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1541,6 +3950,67 @@ 'state': 'middle', }) # --- +# name: test_platform_setup_and_discovery[select.valve_controller_2_weather_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.valve_controller_2_weather_delay', + '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': 'Weather delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'weather_delay', + 'unique_id': 'tuya.kx8dncf1qzkfsweather_delay', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.valve_controller_2_weather_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Weather delay', + 'options': list([ + 'cancel', + '24h', + '48h', + '72h', + ]), + }), + 'context': , + 'entity_id': 'select.valve_controller_2_weather_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[select.wallwasher_front_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1659,3 +4129,121 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_indicator_light_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.weihnachtsmann_indicator_light_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': 'Indicator light mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_mode', + 'unique_id': 'tuya.rwp6kdezm97s2nktzclight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_indicator_light_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Indicator light mode', + 'options': list([ + 'relay', + 'pos', + 'none', + ]), + }), + 'context': , + 'entity_id': 'select.weihnachtsmann_indicator_light_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.weihnachtsmann_power_on_behavior', + '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': 'Power on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcrelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.weihnachtsmann_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Power on behavior', + 'options': list([ + 'power_off', + 'power_on', + 'last', + ]), + }), + 'context': , + 'entity_id': 'select.weihnachtsmann_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index bdccfb56a5c..5b74b6cbfbf 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,178 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[sensor.3dprinter_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.3dprinter_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '3DPrinter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3dprinter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_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.3dprinter_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '3DPrinter Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.3dprinter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_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.3dprinter_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.pykascx9yfqrxtbgzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.3dprinter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '3DPrinter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.3dprinter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.9', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aqi_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -266,6 +440,852 @@ 'state': '0.018', }) # --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_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.aubess_cooker_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Aubess Cooker Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_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.aubess_cooker_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aubess Cooker Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_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.aubess_cooker_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.cju47ovcbeuapei2zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_cooker_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aubess Cooker Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_cooker_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_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.aubess_washing_machine_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Aubess Washing Machine Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_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.aubess_washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aubess Washing Machine Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_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.aubess_washing_machine_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.aubess_washing_machine_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aubess Washing Machine Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aubess_washing_machine_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.balkonbewasserung_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.balkonbewasserung_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.balkonbewasserung_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'balkonbewässerung Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.balkonbewasserung_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_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.basement_temperature_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.jlduh7vigcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement temperature Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_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.basement_temperature_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.jlduh7vigcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Basement temperature Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_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.basement_temperature_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.jlduh7vigcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.basement_temperature_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement temperature Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.basement_temperature_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_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.bassin_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.iaagy4qigcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bassin Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bassin_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_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.bassin_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Bassin Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.783', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_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.bassin_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Bassin Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.bassin_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_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.bassin_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.iaagy4qigcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Bassin Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_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.bassin_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bassin_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Bassin Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bassin_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '245.4', + }) +# --- # name: test_platform_setup_and_discovery[sensor.bathroom_smart_switch_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -319,6 +1339,112 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.boite_aux_lettres_arriere_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.boite_aux_lettres_arriere_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.7obpyhy8scmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.boite_aux_lettres_arriere_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Boîte aux lettres - arrière Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.boite_aux_lettres_arriere_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '62.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bouton_tempo_exterieur_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.bouton_tempo_exterieur_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.g5uso5ajgkxwbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.bouton_tempo_exterieur_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bouton tempo extérieur Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bouton_tempo_exterieur_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.br_7_in_1_wlan_wetterstation_anthrazit_air_pressure-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1185,6 +2311,180 @@ 'state': '2.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.consommation_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.consommation_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Consommation Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consommation_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.585', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_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.consommation_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Consommation Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.consommation_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '425.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_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.consommation_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.consommation_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Consommation Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.consommation_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1518,6 +2818,755 @@ 'state': '222.4', }) # --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_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.eau_chaude_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Eau Chaude Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.067', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_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.eau_chaude_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eau Chaude Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2441.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_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.eau_chaude_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.eau_chaude_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eau Chaude Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eau_chaude_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '241.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_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.elivco_kitchen_socket_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Elivco Kitchen Socket Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_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.elivco_kitchen_socket_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Elivco Kitchen Socket Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_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.elivco_kitchen_socket_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Elivco Kitchen Socket Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_kitchen_socket_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_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.elivco_tv_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Elivco TV Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.091', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_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.elivco_tv_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Elivco TV Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_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.elivco_tv_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.elivco_tv_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Elivco TV Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.elivco_tv_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '237.7', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.fenetre_cuisine_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.fenetre_cuisine_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.yuanswy6scmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.fenetre_cuisine_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Fenêtre cuisine Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.fenetre_cuisine_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '93.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_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.framboisier_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Framboisier Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.framboisier_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_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.framboisier_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Framboisier Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.framboisier_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_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.framboisier_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.51tdkcsamisw9ukycpcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.framboisier_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Framboisier Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.framboisier_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '247.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.frysen_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1731,6 +3780,407 @@ 'state': '22.2', }) # --- +# name: test_platform_setup_and_discovery[sensor.garage_contact_sensor_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.garage_contact_sensor_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.3uqk1csjqplf3uxqscmbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_contact_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Contact Sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garage_contact_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_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.garage_socket_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Garage Socket Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_socket_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_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.garage_socket_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Garage Socket Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.garage_socket_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_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.garage_socket_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.3d4yosotwk27nqxvzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garage_socket_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Garage Socket Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garage_socket_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '235.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_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.garaz_cerpadlo_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Garáž čerpadlo Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_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.garaz_cerpadlo_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Garáž čerpadlo Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_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.garaz_cerpadlo_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.wc6mumew8inrivi9zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.garaz_cerpadlo_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Garáž čerpadlo Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garaz_cerpadlo_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240.7', + }) +# --- # name: test_platform_setup_and_discovery[sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1783,6 +4233,219 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.greenhouse_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Greenhouse Battery state', + }), + 'context': , + 'entity_id': 'sensor.greenhouse_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'middle', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_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.greenhouse_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Greenhouse Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.greenhouse_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_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.greenhouse_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ggwxkj8bwn5y63flgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.greenhouse_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Greenhouse Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.greenhouse_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.2', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hot_water_heat_pump_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.hot_water_heat_pump_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hot_water_heat_pump_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hot Water Heat Pump Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hot_water_heat_pump_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.house_water_level_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1939,6 +4602,329 @@ 'state': 'upper_alarm', }) # --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_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.humy_bain_battery', + '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': 'Battery', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_battery', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Humy bain Battery', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.humy_bain_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_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.humy_bain_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humy bain Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_bain_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '63.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_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.humy_bain_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.ZgXzZULP6dDp4Atvgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_bain_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Humy bain Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.humy_bain_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_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.humy_toilettes_rdc_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.69dth3rxgcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Humy toilettes RDC Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_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.humy_toilettes_rdc_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.69dth3rxgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Humy toilettes RDC Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.8', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_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.humy_toilettes_rdc_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.69dth3rxgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.humy_toilettes_rdc_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Humy toilettes RDC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.humy_toilettes_rdc_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.hvac_meter_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2113,6 +5099,337 @@ 'state': '121.7', }) # --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ifs_std002_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'IFS-STD002 Battery state', + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_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.ifs_std002_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'IFS-STD002 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_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.ifs_std002_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.9wlo8cpzprhiclrkgcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ifs_std002_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'IFS-STD002 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ifs_std002_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_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.ineox_sp2_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Ineox SP2 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.228', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_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.ineox_sp2_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Ineox SP2 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.1', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_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.ineox_sp2_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.vx2owjsg86g2ys93zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ineox_sp2_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ineox SP2 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ineox_sp2_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2210,6 +5527,528 @@ 'state': '42.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.keller_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.keller_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Keller Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.keller_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_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.keller_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Keller Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.keller_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_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.keller_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.g7af6lrt4miugbstcpcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.keller_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Keller Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.keller_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_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.lave_linge_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.g0edqq0wzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Lave linge Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lave_linge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_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.lave_linge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.g0edqq0wzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Lave linge Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.lave_linge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_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.lave_linge_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.g0edqq0wzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.lave_linge_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Lave linge Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.lave_linge_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_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.licht_drucker_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Licht drucker Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_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.licht_drucker_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Licht drucker Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_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.licht_drucker_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.licht_drucker_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Licht drucker Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.licht_drucker_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.lounge_dark_blind_last_operation_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3252,6 +7091,740 @@ 'state': '18.5', }) # --- +# name: test_platform_setup_and_discovery[sensor.office_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.office_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Office Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.253', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_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.office_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Office Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.office_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.9', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_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.office_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.2x473nefusdo7af6zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.office_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Office Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_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.p1_energia_elettrica_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.4', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_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.p1_energia_elettrica_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.314', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_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.p1_energia_elettrica_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '215.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_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.p1_energia_elettrica_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_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.p1_energia_elettrica_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_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.p1_energia_elettrica_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_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.p1_energia_elettrica_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'P1 Energia Elettrica Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_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.p1_energia_elettrica_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'P1 Energia Elettrica Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_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.p1_energia_elettrica_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'P1 Energia Elettrica Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_total_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.p1_energia_elettrica_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldtotal_forward_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.p1_energia_elettrica_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'P1 Energia Elettrica Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.p1_energia_elettrica_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22799.6', + }) +# --- # name: test_platform_setup_and_discovery[sensor.patates_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3462,6 +8035,115 @@ 'state': '22.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_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.pid_relay_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'pid_relay_2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pid_relay_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_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.pid_relay_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.pid_relay_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'pid_relay_2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pid_relay_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.pir_outside_stairs_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3782,6 +8464,174 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.production_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.production_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Production Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.production_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2314.6', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_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.production_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Production Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.production_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1520.21', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_production-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.production_total_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total production', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_production', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzreverse_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.production_total_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Production Total production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.production_total_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.rainwater_tank_level_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3938,6 +8788,180 @@ 'state': 'normal', }) # --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_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.raspy4_home_assistant_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.zaszonjgzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Raspy4 - Home Assistant Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.033', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_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.raspy4_home_assistant_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.zaszonjgzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Raspy4 - Home Assistant Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_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.raspy4_home_assistant_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.zaszonjgzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.raspy4_home_assistant_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Raspy4 - Home Assistant Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.raspy4_home_assistant_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.rat_trap_hedge_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3986,6 +9010,276 @@ 'state': 'low', }) # --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_alexsandro_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rauchmelder_alexsandro_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.o71einxvuuktuljcjbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_alexsandro_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rauchmelder Alexsandro Battery state', + }), + 'context': , + 'entity_id': 'sensor.rauchmelder_alexsandro_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_drucker_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rauchmelder_drucker_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.t5zosev6h6wmwyrajbwybattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.rauchmelder_drucker_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rauchmelder Drucker Battery state', + }), + 'context': , + 'entity_id': 'sensor.rauchmelder_drucker_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_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.sapphire_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Sapphire Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sapphire_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.135', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_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.sapphire_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Sapphire Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.sapphire_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '313.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_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.sapphire_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.hfqeljop3aihnm73zccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.sapphire_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Sapphire Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sapphire_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2357.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smart_odor_eliminator_pro_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4087,6 +9381,59 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.smogo_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.smogo_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.swhtzki3qrz5ydchjbocbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.smogo_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smogo Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smogo_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.smoke_detector_upstairs_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4362,6 +9709,180 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.socket4_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.socket4_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Socket4 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket4_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_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.socket4_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Socket4 Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.socket4_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_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.socket4_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket4_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Socket4 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket4_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.solar_zijpad_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4616,6 +10137,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.steel_cage_door_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.gvxxy4jitzltz5xhscmbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.steel_cage_door_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Steel cage door Battery state', + }), + 'context': , + 'entity_id': 'sensor.steel_cage_door_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- # name: test_platform_setup_and_discovery[sensor.tournesol_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5189,6 +10758,111 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_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': , + 'entity_id': 'sensor.valve_controller_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.kx8dncf1qzkfsbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Valve Controller 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.valve_controller_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_total_watering_time-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.valve_controller_2_total_watering_time', + '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': 'Total watering time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_watering_time', + 'unique_id': 'tuya.kx8dncf1qzkfstime_use', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.valve_controller_2_total_watering_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Total watering time', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.valve_controller_2_total_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.water_fountain_filter_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5293,6 +10967,180 @@ 'state': '7.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_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.weihnachtsmann_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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Weihnachtsmann Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_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.weihnachtsmann_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Weihnachtsmann Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_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.weihnachtsmann_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.rwp6kdezm97s2nktzccur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.weihnachtsmann_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Weihnachtsmann Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.weihnachtsmann_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.wifi_smart_gas_boiler_thermostat_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5346,6 +11194,269 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.wifi_smoke_alarm_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.wifi_smoke_alarm_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.tvgoe1s3fabebcskjbwybattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_smoke_alarm_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WIFI Smoke alarm Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smoke_alarm_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_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.wifi_temperature_humidity_sensor_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery_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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Temperature & Humidity Sensor Battery state', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_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.wifi_temperature_humidity_sensor_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswva_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_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.wifi_temperature_humidity_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.urm7i0rtdlabqiqygcdswva_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.wifi_temperature_humidity_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'WiFi Temperature & Humidity Sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wifi_temperature_humidity_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr index b6d4e0a086e..c907d94dc39 100644 --- a/tests/components/tuya/snapshots/test_siren.ambr +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -48,6 +48,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[siren.burocam-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.burocam', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.svjjuwykgijjedurpssiren_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[siren.burocam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.burocam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[siren.c9-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 57efe39fcd7..493d4827e0a 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1,4 +1,101 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[switch.3dprinter_child_lock-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': , + 'entity_id': 'switch.3dprinter_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '3DPrinter Child lock', + }), + 'context': , + 'entity_id': 'switch.3dprinter_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_socket_1-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.3dprinter_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pykascx9yfqrxtbgzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.3dprinter_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '3DPrinter Socket 1', + }), + 'context': , + 'entity_id': 'switch.3dprinter_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.4_433_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -292,6 +389,493 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_child_lock-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': , + 'entity_id': 'switch.aubess_cooker_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.cju47ovcbeuapei2zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Cooker Child lock', + }), + 'context': , + 'entity_id': 'switch.aubess_cooker_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_socket_1-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.aubess_cooker_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.cju47ovcbeuapei2zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_cooker_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Aubess Cooker Socket 1', + }), + 'context': , + 'entity_id': 'switch.aubess_cooker_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_child_lock-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': , + 'entity_id': 'switch.aubess_washing_machine_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aubess Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'switch.aubess_washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_socket_1-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.aubess_washing_machine_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zjh9xhtm3gibs9kizcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.aubess_washing_machine_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Aubess Washing Machine Socket 1', + }), + 'context': , + 'entity_id': 'switch.aubess_washing_machine_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.auvelico_socket_1-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.auvelico_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.hxbonj4yzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.auvelico_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'AuVeLiCo Socket 1', + }), + 'context': , + 'entity_id': 'switch.auvelico_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-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.balkonbewasserung_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.73ov8i8iedtylkzrqzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.balkonbewasserung_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'balkonbewässerung Switch', + }), + 'context': , + 'entity_id': 'switch.balkonbewasserung_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bassin_socket_1-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.bassin_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.kvnsoqyfltmf0bknzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bassin_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bassin Socket 1', + }), + 'context': , + 'entity_id': 'switch.bassin_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_fan_socket_1-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.bathroom_fan_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.5gfyvvg48bsxbbnjzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_fan_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bathroom Fan Socket 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_fan_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_light_switch_1-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.bathroom_light_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.gluaktf5gkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_light_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'bathroom light Switch 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_light_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_mirror_socket_1-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.bathroom_mirror_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.eway2kw92ncuecarzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.bathroom_mirror_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bathroom Mirror Socket 1', + }), + 'context': , + 'entity_id': 'switch.bathroom_mirror_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -487,6 +1071,342 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.burocam_flip-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': , + 'entity_id': 'switch.burocam_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Flip', + }), + 'context': , + 'entity_id': 'switch.burocam_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.burocam_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion alarm', + }), + 'context': , + 'entity_id': 'switch.burocam_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_tracking-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': , + 'entity_id': 'switch.burocam_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.svjjuwykgijjedurpsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Motion tracking', + }), + 'context': , + 'entity_id': 'switch.burocam_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_privacy_mode-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': , + 'entity_id': 'switch.burocam_privacy_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': 'Privacy mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_private', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Privacy mode', + }), + 'context': , + 'entity_id': 'switch.burocam_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_sound_detection-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': , + 'entity_id': 'switch.burocam_sound_detection', + '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': 'Sound detection', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sound_detection', + 'unique_id': 'tuya.svjjuwykgijjedurpsdecibel_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_sound_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Sound detection', + }), + 'context': , + 'entity_id': 'switch.burocam_sound_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_time_watermark-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': , + 'entity_id': 'switch.burocam_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.svjjuwykgijjedurpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Time watermark', + }), + 'context': , + 'entity_id': 'switch.burocam_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_video_recording-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': , + 'entity_id': 'switch.burocam_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.svjjuwykgijjedurpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.burocam_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bürocam Video recording', + }), + 'context': , + 'entity_id': 'switch.burocam_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.c9_flip-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1351,6 +2271,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_1-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.consommation_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Consommation Socket 1', + }), + 'context': , + 'entity_id': 'switch.consommation_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_2-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.consommation_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.49m7h9lh3t8pq6ftzcswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.consommation_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Consommation Socket 2', + }), + 'context': , + 'entity_id': 'switch.consommation_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1547,6 +2565,296 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_child_lock-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': , + 'entity_id': 'switch.eau_chaude_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eau Chaude Child lock', + }), + 'context': , + 'entity_id': 'switch.eau_chaude_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_switch-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.eau_chaude_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.a3qtb7pulkcc6jdjqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.eau_chaude_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eau Chaude Switch', + }), + 'context': , + 'entity_id': 'switch.eau_chaude_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-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': , + 'entity_id': 'switch.elivco_kitchen_socket_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco Kitchen Socket Child lock', + }), + 'context': , + 'entity_id': 'switch.elivco_kitchen_socket_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_socket_1-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.elivco_kitchen_socket_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.cq4hzlrnqn4qi0mqzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Elivco Kitchen Socket Socket 1', + }), + 'context': , + 'entity_id': 'switch.elivco_kitchen_socket_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_child_lock-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': , + 'entity_id': 'switch.elivco_tv_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elivco TV Child lock', + }), + 'context': , + 'entity_id': 'switch.elivco_tv_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_socket_1-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.elivco_tv_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.pz2xuth8hczv6zrwzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.elivco_tv_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Elivco TV Socket 1', + }), + 'context': , + 'entity_id': 'switch.elivco_tv_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.fakkel_veranda_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1596,6 +2904,152 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.framboisier_child_lock-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': , + 'entity_id': 'switch.framboisier_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.51tdkcsamisw9ukycpchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Framboisier Child lock', + }), + 'context': , + 'entity_id': 'switch.framboisier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_1-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.framboisier_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.51tdkcsamisw9ukycpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisier Socket 1', + }), + 'context': , + 'entity_id': 'switch.framboisier_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_2-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.framboisier_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.51tdkcsamisw9ukycpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.framboisier_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Framboisier Socket 2', + }), + 'context': , + 'entity_id': 'switch.framboisier_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.framboisiers_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1645,6 +3099,440 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.garage_camera_flip-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': , + 'entity_id': 'switch.garage_camera_flip', + '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': 'Flip', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Flip', + }), + 'context': , + 'entity_id': 'switch.garage_camera_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.garage_camera_motion_alarm', + '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': 'Motion alarm', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion alarm', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_recording-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': , + 'entity_id': 'switch.garage_camera_motion_recording', + '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': 'Motion recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_recording', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion recording', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_tracking-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': , + 'entity_id': 'switch.garage_camera_motion_tracking', + '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': 'Motion tracking', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_tracking', + 'unique_id': 'tuya.53fnjncm3jywuaznpsmotion_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_motion_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Motion tracking', + }), + 'context': , + 'entity_id': 'switch.garage_camera_motion_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_privacy_mode-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': , + 'entity_id': 'switch.garage_camera_privacy_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': 'Privacy mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_private', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Privacy mode', + }), + 'context': , + 'entity_id': 'switch.garage_camera_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_time_watermark-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': , + 'entity_id': 'switch.garage_camera_time_watermark', + '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': 'Time watermark', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'time_watermark', + 'unique_id': 'tuya.53fnjncm3jywuaznpsbasic_osd', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_time_watermark-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Time watermark', + }), + 'context': , + 'entity_id': 'switch.garage_camera_time_watermark', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_video_recording-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': , + 'entity_id': 'switch.garage_camera_video_recording', + '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': 'Video recording', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'video_recording', + 'unique_id': 'tuya.53fnjncm3jywuaznpsrecord_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_camera_video_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Camera Video recording', + }), + 'context': , + 'entity_id': 'switch.garage_camera_video_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_socket_socket_1-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.garage_socket_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.3d4yosotwk27nqxvzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garage_socket_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Garage Socket Socket 1', + }), + 'context': , + 'entity_id': 'switch.garage_socket_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.garaz_cerpadlo_socket-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.garaz_cerpadlo_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.wc6mumew8inrivi9zcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.garaz_cerpadlo_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Garáž čerpadlo Socket', + }), + 'context': , + 'entity_id': 'switch.garaz_cerpadlo_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.hl400_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1885,6 +3773,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.hot_water_heat_pump_switch-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.hot_water_heat_pump_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.ol8xwtcj42eg18bdbrnzswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.hot_water_heat_pump_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hot Water Heat Pump Switch', + }), + 'context': , + 'entity_id': 'switch.hot_water_heat_pump_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.hvac_meter_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1983,6 +3919,343 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_child_lock-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': , + 'entity_id': 'switch.ineox_sp2_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ineox SP2 Child lock', + }), + 'context': , + 'entity_id': 'switch.ineox_sp2_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_socket_1-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.ineox_sp2_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.vx2owjsg86g2ys93zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ineox_sp2_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Ineox SP2 Socket 1', + }), + 'context': , + 'entity_id': 'switch.ineox_sp2_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_child_lock-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': , + 'entity_id': 'switch.ion1000pro_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.owozxdzgbibizu4sjklock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Child lock', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_filter_cartridge_reset-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': , + 'entity_id': 'switch.ion1000pro_filter_cartridge_reset', + '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': 'Filter cartridge reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cartridge_reset', + 'unique_id': 'tuya.owozxdzgbibizu4sjkfilter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_filter_cartridge_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Filter cartridge reset', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_filter_cartridge_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_ionizer-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': , + 'entity_id': 'switch.ion1000pro_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.owozxdzgbibizu4sjkanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Ionizer', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_power-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.ion1000pro_power', + '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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.owozxdzgbibizu4sjkswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO Power', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_uv_sterilization-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': , + 'entity_id': 'switch.ion1000pro_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.owozxdzgbibizu4sjkuv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.ion1000pro_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO UV sterilization', + }), + 'context': , + 'entity_id': 'switch.ion1000pro_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.jardin_fraises_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2224,6 +4497,347 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.keller_socket_1-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.keller_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 1', + }), + 'context': , + 'entity_id': 'switch.keller_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_2-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.keller_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 2', + }), + 'context': , + 'entity_id': 'switch.keller_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_3-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.keller_socket_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_socket_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Keller Socket 3', + }), + 'context': , + 'entity_id': 'switch.keller_socket_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_usb_1-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.keller_usb_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': 'USB 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_usb', + 'unique_id': 'tuya.g7af6lrt4miugbstcpswitch_usb1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.keller_usb_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Keller USB 1', + }), + 'context': , + 'entity_id': 'switch.keller_usb_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_child_lock-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': , + 'entity_id': 'switch.lave_linge_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.g0edqq0wzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lave linge Child lock', + }), + 'context': , + 'entity_id': 'switch.lave_linge_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_socket_1-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.lave_linge_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.g0edqq0wzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.lave_linge_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Lave linge Socket 1', + }), + 'context': , + 'entity_id': 'switch.lave_linge_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.licht_drucker_socket_1-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.licht_drucker_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.uvh6oeqrfliovfiwzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.licht_drucker_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Licht drucker Socket 1', + }), + 'context': , + 'entity_id': 'switch.licht_drucker_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.lounge_dark_blind_reverse-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2368,6 +4982,298 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.office_child_lock-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': , + 'entity_id': 'switch.office_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.2x473nefusdo7af6zcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Child lock', + }), + 'context': , + 'entity_id': 'switch.office_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_lights_switch_1-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.office_lights_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.O8QpxJwdme33sqn4gkswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_lights_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'office lights Switch 1', + }), + 'context': , + 'entity_id': 'switch.office_lights_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_socket_1-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.office_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.2x473nefusdo7af6zcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.office_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Office Socket 1', + }), + 'context': , + 'entity_id': 'switch.office_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.p1_energia_elettrica_switch-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.p1_energia_elettrica_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bcyciyhhu1g2gk9rqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.p1_energia_elettrica_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'P1 Energia Elettrica Switch', + }), + 'context': , + 'entity_id': 'switch.p1_energia_elettrica_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_1-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.pid_relay_2_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'pid_relay_2 Switch 1', + }), + 'context': , + 'entity_id': 'switch.pid_relay_2_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_2-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.pid_relay_2_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_switch', + 'unique_id': 'tuya.zyutbek7wdm1b4cgzckwswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pid_relay_2_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'pid_relay_2 Switch 2', + }), + 'context': , + 'entity_id': 'switch.pid_relay_2_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2657,6 +5563,201 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_child_lock-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': , + 'entity_id': 'switch.raspy4_home_assistant_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.zaszonjgzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Raspy4 - Home Assistant Child lock', + }), + 'context': , + 'entity_id': 'switch.raspy4_home_assistant_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_socket_1-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.raspy4_home_assistant_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.zaszonjgzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.raspy4_home_assistant_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Raspy4 - Home Assistant Socket 1', + }), + 'context': , + 'entity_id': 'switch.raspy4_home_assistant_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.rewireable_plug_6930ha_socket_1-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.rewireable_plug_6930ha_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.LS6FfVBVU1vzBRBHzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.rewireable_plug_6930ha_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Rewireable Plug 6930HA Socket 1', + }), + 'context': , + 'entity_id': 'switch.rewireable_plug_6930ha_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sapphire_socket-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.sapphire_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.hfqeljop3aihnm73zcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sapphire_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Sapphire Socket', + }), + 'context': , + 'entity_id': 'switch.sapphire_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.seating_side_6_ch_smart_switch_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2999,6 +6100,103 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.security_light_child_lock-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': , + 'entity_id': 'switch.security_light_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Light Child lock', + }), + 'context': , + 'entity_id': 'switch.security_light_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_socket_1-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.security_light_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.bxfkpxjgux2fgwnazcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.security_light_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Security Light Socket 1', + }), + 'context': , + 'entity_id': 'switch.security_light_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.smart_odor_eliminator_pro_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3192,6 +6390,103 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.socket4_child_lock-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': , + 'entity_id': 'switch.socket4_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Socket4 Child lock', + }), + 'context': , + 'entity_id': 'switch.socket4_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_socket_1-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.socket4_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.4q5c2am8n1bwb6bszcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.socket4_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Socket4 Socket 1', + }), + 'context': , + 'entity_id': 'switch.socket4_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.solar_zijpad_energy_saving-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3288,6 +6583,152 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[switch.spot_1_child_lock-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': , + 'entity_id': 'switch.spot_1_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spot 1 Child lock', + }), + 'context': , + 'entity_id': 'switch.spot_1_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_socket_1-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.spot_1_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.kffnst1epj6vr8xnzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_1_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spot 1 Socket 1', + }), + 'context': , + 'entity_id': 'switch.spot_1_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_4_socket_1-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.spot_4_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.LJ9zTFQTfMgsG2Ahzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.spot_4_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Spot 4 Socket 1', + }), + 'context': , + 'entity_id': 'switch.spot_4_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3336,6 +6777,355 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.steckdose_2_socket-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.steckdose_2_socket', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'tuya.HzsAAAKFLPABVi8nzcswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.steckdose_2_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Steckdose 2 Socket', + }), + 'context': , + 'entity_id': 'switch.steckdose_2_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_power-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.sunbeam_bedding_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power', + 'original_name': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Power', + 'icon': 'mdi:power', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_preheat-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.sunbeam_bedding_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_power-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.sunbeam_bedding_side_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:alpha-a', + 'original_name': 'Side A Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side A Power', + 'icon': 'mdi:alpha-a', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_preheat-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.sunbeam_bedding_side_a_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Side A Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_a_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side A Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_a_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_power-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.sunbeam_bedding_side_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:alpha-b', + 'original_name': 'Side B Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side B Power', + 'icon': 'mdi:alpha-b', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_preheat-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.sunbeam_bedding_side_b_preheat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Side B Preheat', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.fasvixqysw1lxvjprdpreheat_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.sunbeam_bedding_side_b_preheat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Sunbeam Bedding Side B Preheat', + 'icon': 'mdi:radiator', + }), + 'context': , + 'entity_id': 'switch.sunbeam_bedding_side_b_preheat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.term_prizemi_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3578,6 +7368,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-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.valve_controller_2_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.kx8dncf1qzkfsswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.valve_controller_2_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve Controller 2 Switch', + }), + 'context': , + 'entity_id': 'switch.valve_controller_2_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.wallwasher_front_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3819,6 +7657,103 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-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': , + 'entity_id': 'switch.weihnachtsmann_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Weihnachtsmann Child lock', + }), + 'context': , + 'entity_id': 'switch.weihnachtsmann_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_socket_1-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.weihnachtsmann_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'indexed_socket', + 'unique_id': 'tuya.rwp6kdezm97s2nktzcswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.weihnachtsmann_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Weihnachtsmann Socket 1', + }), + 'context': , + 'entity_id': 'switch.weihnachtsmann_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_vacuum.ambr b/tests/components/tuya/snapshots/test_vacuum.ambr index fe0b2fbce97..301a9ea8261 100644 --- a/tests/components/tuya/snapshots/test_vacuum.ambr +++ b/tests/components/tuya/snapshots/test_vacuum.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[vacuum.hoover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.hoover', + '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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.mwsaod7fa3gjyh6ids', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[vacuum.hoover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hoover', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.hoover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[vacuum.v20-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From ab04e2c5013dfd33d4908e9510c12677d41e8cb9 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sun, 10 Aug 2025 13:26:43 -0700 Subject: [PATCH 1082/1118] TotalConnect major test updates (#139672) Co-authored-by: Joostlek --- .../components/totalconnect/binary_sensor.py | 4 +- .../components/totalconnect/config_flow.py | 6 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/__init__.py | 12 + tests/components/totalconnect/common.py | 473 -- tests/components/totalconnect/conftest.py | 249 ++ tests/components/totalconnect/const.py | 8 + .../totalconnect/fixtures/device_1.json | 12 + .../totalconnect/fixtures/zones.json | 658 +++ .../snapshots/test_alarm_control_panel.ambr | 12 +- .../snapshots/test_binary_sensor.ambr | 3852 ++++++++++++++++- .../totalconnect/snapshots/test_button.ambr | 550 ++- .../snapshots/test_diagnostics.ambr | 619 +++ .../totalconnect/test_alarm_control_panel.py | 829 ++-- .../totalconnect/test_binary_sensor.py | 88 +- tests/components/totalconnect/test_button.py | 104 +- .../totalconnect/test_config_flow.py | 364 +- .../totalconnect/test_diagnostics.py | 39 +- tests/components/totalconnect/test_init.py | 20 +- 21 files changed, 6374 insertions(+), 1531 deletions(-) delete mode 100644 tests/components/totalconnect/common.py create mode 100644 tests/components/totalconnect/conftest.py create mode 100644 tests/components/totalconnect/const.py create mode 100644 tests/components/totalconnect/fixtures/device_1.json create mode 100644 tests/components/totalconnect/fixtures/zones.json create mode 100644 tests/components/totalconnect/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 2f3802dc9a6..7cc8d7a5ebc 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -172,9 +172,9 @@ class TotalConnectZoneBinarySensor(TotalConnectZoneEntity, BinarySensorEntity): super().__init__(coordinator, zone, location_id, entity_description.key) self.entity_description = entity_description self._attr_extra_state_attributes = { - "zone_id": zone.zoneid, + "zone_id": str(zone.zoneid), "location_id": location_id, - "partition": zone.partition, + "partition": str(zone.partition), } @property diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 3f5d05fda13..33e82dcaf53 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -105,11 +105,7 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): }, ) else: - # Force the loading of locations using I/O - number_locations = await self.hass.async_add_executor_job( - self.client.get_number_locations, - ) - if number_locations < 1: + if self.client.get_number_locations() < 1: return self.async_abort(reason="no_locations") for location_id in self.client.locations: self.usercodes[location_id] = None diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 6aff1ea392b..cd349cd3414 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.1.4"] + "requirements": ["total-connect-client==2025.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a41f2809f2a..599f8031dfc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2965,7 +2965,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.1.4 +total-connect-client==2025.5 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d83bbfad62..668dd2ad35a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2442,7 +2442,7 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.1.4 +total-connect-client==2025.5 # homeassistant.components.tplink_omada tplink-omada-client==1.4.4 diff --git a/tests/components/totalconnect/__init__.py b/tests/components/totalconnect/__init__.py index 180a00188cd..e7b358157cb 100644 --- a/tests/components/totalconnect/__init__.py +++ b/tests/components/totalconnect/__init__.py @@ -1 +1,13 @@ """Tests for the totalconnect component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py deleted file mode 100644 index 34d451ec0b8..00000000000 --- a/tests/components/totalconnect/common.py +++ /dev/null @@ -1,473 +0,0 @@ -"""Common methods used across tests for TotalConnect.""" - -from typing import Any -from unittest.mock import patch - -from total_connect_client import ArmingState, ResultCode, ZoneStatus, ZoneType - -from homeassistant.components.totalconnect.const import ( - AUTO_BYPASS, - CODE_REQUIRED, - CONF_USERCODES, - DOMAIN, -) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -LOCATION_ID = 123456 - -DEVICE_INFO_BASIC_1 = { - "DeviceID": "987654", - "DeviceName": "test", - "DeviceClassID": 1, - "DeviceSerialNumber": "987654321ABC", - "DeviceFlags": "PromptForUserCode=0,PromptForInstallerCode=0,PromptForImportSecuritySettings=0,AllowUserSlotEditing=0,CalCapable=1,CanBeSentToPanel=0,CanArmNightStay=0,CanSupportMultiPartition=0,PartitionCount=0,MaxPartitionCount=0,OnBoardingSupport=0,PartitionAdded=0,DuplicateUserSyncStatus=0,PanelType=8,PanelVariant=1,BLEDisarmCapable=0,ArmHomeSupported=0,DuplicateUserCodeCheck=1,CanSupportRapid=0,IsKeypadSupported=1,WifiEnrollmentSupported=0,IsConnectedPanel=0,ArmNightInSceneSupported=0,BuiltInCameraSettingsSupported=0,ZWaveThermostatScheduleDisabled=0,MultipleAuthorityLevelSupported=0,VideoOnPanelSupported=0,EnableBLEMode=0,IsPanelWiFiResetSupported=0,IsCompetitorClearBypass=0,IsNotReadyStateSupported=0,isArmStatusWithoutExitDelayNotSupported=0", - "SecurityPanelTypeID": None, - "DeviceSerialText": None, -} -DEVICE_LIST = [DEVICE_INFO_BASIC_1] - -LOCATION_INFO_BASIC_NORMAL = { - "LocationID": LOCATION_ID, - "LocationName": "test", - "SecurityDeviceID": "987654", - "PhotoURL": "http://www.example.com/some/path/to/file.jpg", - "LocationModuleFlags": "Security=1,Video=0,Automation=0,GPS=0,VideoPIR=0", - "DeviceList": {"DeviceInfoBasic": DEVICE_LIST}, -} - -LOCATIONS = {"LocationInfoBasic": [LOCATION_INFO_BASIC_NORMAL]} - -MODULE_FLAGS = "Some=0,Fake=1,Flags=2" - -USER = { - "UserID": "1234567", - "Username": "username", - "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", -} - -RESPONSE_SESSION_DETAILS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "Success", - "SessionID": "12345", - "Locations": LOCATIONS, - "ModuleFlags": MODULE_FLAGS, - "UserInfo": USER, -} - -PARTITION_DISARMED = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_DISARMED2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_ARMED_STAY = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_STAY, -} - -PARTITION_ARMED_STAY2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED, -} - -PARTITION_ARMED_AWAY = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_AWAY, -} - -PARTITION_ARMED_CUSTOM = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_CUSTOM_BYPASS, -} - -PARTITION_ARMED_NIGHT = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMED_STAY_NIGHT, -} - -PARTITION_ARMING = { - "PartitionID": "1", - "ArmingState": ArmingState.ARMING, -} -PARTITION_DISARMING = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMING, -} - -PARTITION_TRIGGERED_POLICE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING, -} - -PARTITION_TRIGGERED_FIRE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING_FIRE_SMOKE, -} - -PARTITION_TRIGGERED_CARBON_MONOXIDE = { - "PartitionID": "1", - "ArmingState": ArmingState.ALARMING_CARBON_MONOXIDE, -} - -PARTITION_UNKNOWN = { - "PartitionID": "1", - "ArmingState": "99999", -} - - -PARTITION_INFO_DISARMED = [PARTITION_DISARMED, PARTITION_DISARMED2] -PARTITION_INFO_ARMED_STAY = [PARTITION_ARMED_STAY, PARTITION_ARMED_STAY2] -PARTITION_INFO_ARMED_AWAY = [PARTITION_ARMED_AWAY] -PARTITION_INFO_ARMED_CUSTOM = [PARTITION_ARMED_CUSTOM] -PARTITION_INFO_ARMED_NIGHT = [PARTITION_ARMED_NIGHT] -PARTITION_INFO_ARMING = [PARTITION_ARMING] -PARTITION_INFO_DISARMING = [PARTITION_DISARMING] -PARTITION_INFO_TRIGGERED_POLICE = [PARTITION_TRIGGERED_POLICE] -PARTITION_INFO_TRIGGERED_FIRE = [PARTITION_TRIGGERED_FIRE] -PARTITION_INFO_TRIGGERED_CARBON_MONOXIDE = [PARTITION_TRIGGERED_CARBON_MONOXIDE] -PARTITION_INFO_UNKNOWN = [PARTITION_UNKNOWN] - -PARTITIONS_DISARMED = {"PartitionInfo": PARTITION_INFO_DISARMED} -PARTITIONS_ARMED_STAY = {"PartitionInfo": PARTITION_INFO_ARMED_STAY} -PARTITIONS_ARMED_AWAY = {"PartitionInfo": PARTITION_INFO_ARMED_AWAY} -PARTITIONS_ARMED_CUSTOM = {"PartitionInfo": PARTITION_INFO_ARMED_CUSTOM} -PARTITIONS_ARMED_NIGHT = {"PartitionInfo": PARTITION_INFO_ARMED_NIGHT} -PARTITIONS_ARMING = {"PartitionInfo": PARTITION_INFO_ARMING} -PARTITIONS_DISARMING = {"PartitionInfo": PARTITION_INFO_DISARMING} -PARTITIONS_TRIGGERED_POLICE = {"PartitionInfo": PARTITION_INFO_TRIGGERED_POLICE} -PARTITIONS_TRIGGERED_FIRE = {"PartitionInfo": PARTITION_INFO_TRIGGERED_FIRE} -PARTITIONS_TRIGGERED_CARBON_MONOXIDE = { - "PartitionInfo": PARTITION_INFO_TRIGGERED_CARBON_MONOXIDE -} -PARTITIONS_UNKNOWN = {"PartitionInfo": PARTITION_INFO_UNKNOWN} - -ZONE_NORMAL = { - "ZoneID": "1", - "ZoneDescription": "Security", - "ZoneStatus": ZoneStatus.FAULT, - "ZoneTypeId": ZoneType.SECURITY, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_2 = { - "ZoneID": "2", - "ZoneDescription": "Fire", - "ZoneStatus": ZoneStatus.LOW_BATTERY, - "ZoneTypeId": ZoneType.FIRE_SMOKE, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_3 = { - "ZoneID": "3", - "ZoneDescription": "Gas", - "ZoneStatus": ZoneStatus.TAMPER, - "ZoneTypeId": ZoneType.CARBON_MONOXIDE, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_4 = { - "ZoneID": "4", - "ZoneDescription": "Motion", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.INTERIOR_FOLLOWER, - "PartitionId": "1", - "CanBeBypassed": 1, -} -ZONE_5 = { - "ZoneID": "5", - "ZoneDescription": "Medical", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.PROA7_MEDICAL, - "PartitionId": "1", - "CanBeBypassed": 0, -} -# 99 is an unknown ZoneType -ZONE_6 = { - "ZoneID": "6", - "ZoneDescription": "Unknown", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": 99, - "PartitionId": "1", - "CanBeBypassed": 0, -} - -ZONE_7 = { - "ZoneID": 7, - "ZoneDescription": "Temperature", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.MONITOR, - "PartitionId": "1", - "CanBeBypassed": 0, -} - -# ZoneType security that cannot be bypassed is a Button on the alarm panel -ZONE_8 = { - "ZoneID": 8, - "ZoneDescription": "Button", - "ZoneStatus": ZoneStatus.FAULT, - "ZoneTypeId": ZoneType.SECURITY, - "PartitionId": "1", - "CanBeBypassed": 0, -} - - -ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6, ZONE_7] -ZONES = {"ZoneInfo": ZONE_INFO} - -METADATA_DISARMED = { - "Partitions": PARTITIONS_DISARMED, - "Zones": ZONES, - "PromptForImportSecuritySettings": False, - "IsInACLoss": False, - "IsCoverTampered": False, - "Bell1SupervisionFailure": False, - "Bell2SupervisionFailure": False, - "IsInLowBattery": False, -} - -METADATA_ARMED_STAY = METADATA_DISARMED.copy() -METADATA_ARMED_STAY["Partitions"] = PARTITIONS_ARMED_STAY - -METADATA_ARMED_AWAY = METADATA_DISARMED.copy() -METADATA_ARMED_AWAY["Partitions"] = PARTITIONS_ARMED_AWAY - -METADATA_ARMED_CUSTOM = METADATA_DISARMED.copy() -METADATA_ARMED_CUSTOM["Partitions"] = PARTITIONS_ARMED_CUSTOM - -METADATA_ARMED_NIGHT = METADATA_DISARMED.copy() -METADATA_ARMED_NIGHT["Partitions"] = PARTITIONS_ARMED_NIGHT - -METADATA_ARMING = METADATA_DISARMED.copy() -METADATA_ARMING["Partitions"] = PARTITIONS_ARMING - -METADATA_DISARMING = METADATA_DISARMED.copy() -METADATA_DISARMING["Partitions"] = PARTITIONS_DISARMING - -METADATA_TRIGGERED_POLICE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_POLICE["Partitions"] = PARTITIONS_TRIGGERED_POLICE - -METADATA_TRIGGERED_FIRE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_FIRE["Partitions"] = PARTITIONS_TRIGGERED_FIRE - -METADATA_TRIGGERED_CARBON_MONOXIDE = METADATA_DISARMED.copy() -METADATA_TRIGGERED_CARBON_MONOXIDE["Partitions"] = PARTITIONS_TRIGGERED_CARBON_MONOXIDE - -METADATA_UNKNOWN = METADATA_DISARMED.copy() -METADATA_UNKNOWN["Partitions"] = PARTITIONS_UNKNOWN - -RESPONSE_DISARMED = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_DISARMED, - "ArmingState": ArmingState.DISARMED, -} -RESPONSE_ARMED_STAY = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_STAY, - "ArmingState": ArmingState.ARMED_STAY, -} -RESPONSE_ARMED_AWAY = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_AWAY, - "ArmingState": ArmingState.ARMED_AWAY, -} -RESPONSE_ARMED_CUSTOM = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_CUSTOM, - "ArmingState": ArmingState.ARMED_CUSTOM_BYPASS, -} -RESPONSE_ARMED_NIGHT = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMED_NIGHT, - "ArmingState": ArmingState.ARMED_STAY_NIGHT, -} -RESPONSE_ARMING = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_ARMING, - "ArmingState": ArmingState.ARMING, -} -RESPONSE_DISARMING = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_DISARMING, - "ArmingState": ArmingState.DISARMING, -} -RESPONSE_TRIGGERED_POLICE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_POLICE, - "ArmingState": ArmingState.ALARMING, -} -RESPONSE_TRIGGERED_FIRE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_FIRE, - "ArmingState": ArmingState.ALARMING_FIRE_SMOKE, -} -RESPONSE_TRIGGERED_CARBON_MONOXIDE = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_TRIGGERED_CARBON_MONOXIDE, - "ArmingState": ArmingState.ALARMING_CARBON_MONOXIDE, -} -RESPONSE_UNKNOWN = { - "ResultCode": 0, - "PanelMetadataAndStatus": METADATA_UNKNOWN, - "ArmingState": ArmingState.DISARMED, -} - -RESPONSE_ARM_SUCCESS = {"ResultCode": ResultCode.ARM_SUCCESS.value} -RESPONSE_ARM_FAILURE = {"ResultCode": ResultCode.COMMAND_FAILED.value} -RESPONSE_DISARM_SUCCESS = {"ResultCode": ResultCode.DISARM_SUCCESS.value} -RESPONSE_DISARM_FAILURE = { - "ResultCode": ResultCode.COMMAND_FAILED.value, - "ResultData": "Command Failed", -} -RESPONSE_USER_CODE_INVALID = { - "ResultCode": ResultCode.USER_CODE_INVALID.value, - "ResultData": "testing user code invalid", -} -RESPONSE_SUCCESS = {"ResultCode": ResultCode.SUCCESS.value} -RESPONSE_ZONE_BYPASS_SUCCESS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "None", -} -RESPONSE_ZONE_BYPASS_FAILURE = { - "ResultCode": ResultCode.FAILED_TO_BYPASS_ZONE.value, - "ResultData": "None", -} - -USERNAME = "username@me.com" -PASSWORD = "password" -USERCODES = {LOCATION_ID: "7890"} -CONFIG_DATA = { - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_USERCODES: USERCODES, -} -CONFIG_DATA_NO_USERCODES = {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} - -OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} -OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} - -PARTITION_DETAILS_1 = { - "PartitionID": "1", - "ArmingState": ArmingState.DISARMED.value, - "PartitionName": "Test1", -} - -PARTITION_DETAILS_2 = { - "PartitionID": "2", - "ArmingState": ArmingState.DISARMED.value, - "PartitionName": "Test2", -} - -PARTITION_DETAILS = {"PartitionDetails": [PARTITION_DETAILS_1, PARTITION_DETAILS_2]} -RESPONSE_PARTITION_DETAILS = { - "ResultCode": ResultCode.SUCCESS.value, - "ResultData": "testing partition details", - "PartitionsInfoList": PARTITION_DETAILS, -} - -ZONE_DETAILS_NORMAL = { - "PartitionId": "1", - "Batterylevel": "-1", - "Signalstrength": "-1", - "zoneAdditionalInfo": None, - "ZoneID": "1", - "ZoneStatus": ZoneStatus.NORMAL, - "ZoneTypeId": ZoneType.SECURITY, - "CanBeBypassed": 1, - "ZoneFlags": None, -} - -ZONE_STATUS_INFO = [ZONE_DETAILS_NORMAL] -ZONE_DETAILS = {"ZoneStatusInfoWithPartitionId": ZONE_STATUS_INFO} -ZONE_DETAIL_STATUS = {"Zones": ZONE_DETAILS} - -RESPONSE_GET_ZONE_DETAILS_SUCCESS = { - "ResultCode": 0, - "ResultData": "Success", - "ZoneStatus": ZONE_DETAIL_STATUS, -} - -TOTALCONNECT_REQUEST = ( - "homeassistant.components.totalconnect.TotalConnectClient.request" -) -TOTALCONNECT_GET_CONFIG = ( - "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" -) -TOTALCONNECT_REQUEST_TOKEN = ( - "homeassistant.components.totalconnect.TotalConnectClient._request_token" -) - - -async def setup_platform( - hass: HomeAssistant, platform: Any, code_required: bool = False -) -> MockConfigEntry: - """Set up the TotalConnect platform.""" - # first set up a config entry and add it to hass - if code_required: - mock_entry = MockConfigEntry( - domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA_CODE_REQUIRED - ) - else: - mock_entry = MockConfigEntry( - domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA - ) - mock_entry.add_to_hass(hass) - - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] - - with ( - patch("homeassistant.components.totalconnect.PLATFORMS", [platform]), - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - assert await async_setup_component(hass, DOMAIN, {}) - assert mock_request.call_count == 5 - await hass.async_block_till_done() - - return mock_entry - - -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: - """Set up the TotalConnect integration.""" - # first set up a config entry and add it to hass - mock_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA) - mock_entry.add_to_hass(hass) - - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] - - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - await hass.config_entries.async_setup(mock_entry.entry_id) - assert mock_request.call_count == 5 - await hass.async_block_till_done() - - return mock_entry diff --git a/tests/components/totalconnect/conftest.py b/tests/components/totalconnect/conftest.py new file mode 100644 index 00000000000..803fc052129 --- /dev/null +++ b/tests/components/totalconnect/conftest.py @@ -0,0 +1,249 @@ +"""Configure py.test.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from total_connect_client import ArmingState, TotalConnectClient +from total_connect_client.device import TotalConnectDevice +from total_connect_client.location import TotalConnectLocation +from total_connect_client.partition import TotalConnectPartition +from total_connect_client.user import TotalConnectUser +from total_connect_client.zone import TotalConnectZone, ZoneStatus, ZoneType + +from homeassistant.components.totalconnect.const import ( + AUTO_BYPASS, + CODE_REQUIRED, + CONF_USERCODES, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CODE, LOCATION_ID, PASSWORD, USERCODES, USERNAME + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +def create_mock_zone( + identifier: int, + partition: str, + description: str, + status: ZoneStatus, + zone_type_id: int, + can_be_bypassed: bool, + battery_level: int, + signal_strength: int, + sensor_serial_number: str | None, + loop_number: int | None, + response_type: str | None, + alarm_report_state: str | None, + supervision_type: str | None, + chime_state: str | None, + device_type: str | None, +) -> AsyncMock: + """Create a mock TotalConnectZone.""" + zone = AsyncMock(spec=TotalConnectZone, autospec=True) + zone.zoneid = identifier + zone.partition = partition + zone.description = description + zone.status = status + zone.zone_type_id = zone_type_id + zone.can_be_bypassed = can_be_bypassed + zone.battery_level = battery_level + zone.signal_strength = signal_strength + zone.sensor_serial_number = sensor_serial_number + zone.loop_number = loop_number + zone.response_type = response_type + zone.alarm_report_state = alarm_report_state + zone.supervision_type = supervision_type + zone.chime_state = chime_state + zone.device_type = device_type + zone.is_type_security.return_value = zone_type_id in ( + ZoneType.SECURITY, + ZoneType.ENTRY_EXIT1, + ZoneType.ENTRY_EXIT2, + ZoneType.PERIMETER, + ZoneType.INTERIOR_FOLLOWER, + ZoneType.TROUBLE_ALARM, + ZoneType.SILENT_24HR, + ZoneType.AUDIBLE_24HR, + ZoneType.INTERIOR_DELAY, + ZoneType.LYRIC_LOCAL_ALARM, + ZoneType.PROA7_GARAGE_MONITOR, + ) + zone.is_type_button.return_value = ( + zone.is_type_security.return_value and not can_be_bypassed + ) or zone_type_id in ( + ZoneType.PROA7_MEDICAL, + ZoneType.AUDIBLE_24HR, + ZoneType.SILENT_24HR, + ZoneType.RF_ARM_STAY, + ZoneType.RF_ARM_AWAY, + ZoneType.RF_DISARM, + ) + return zone + + +def create_mock_zone_from_dict( + zone_data: dict[str, Any], +) -> AsyncMock: + """Create a mock TotalConnectZone from a dictionary.""" + return create_mock_zone( + zone_data["ZoneID"], + zone_data["PartitionId"], + zone_data["ZoneDescription"], + ZoneStatus(zone_data["ZoneStatus"]), + zone_data["ZoneTypeId"], + zone_data["CanBeBypassed"], + zone_data.get("Batterylevel"), + zone_data.get("Signalstrength"), + (zone_data["zoneAdditionalInfo"] or {}).get("SensorSerialNumber"), + (zone_data["zoneAdditionalInfo"] or {}).get("LoopNumber"), + (zone_data["zoneAdditionalInfo"] or {}).get("ResponseType"), + (zone_data["zoneAdditionalInfo"] or {}).get("AlarmReportState"), + (zone_data["zoneAdditionalInfo"] or {}).get("ZoneSupervisionType"), + (zone_data["zoneAdditionalInfo"] or {}).get("ChimeState"), + (zone_data["zoneAdditionalInfo"] or {}).get("DeviceType"), + ) + + +@pytest.fixture +def mock_partition() -> TotalConnectPartition: + """Create a mock TotalConnectPartition.""" + partition = AsyncMock(spec=TotalConnectPartition, autospec=True) + partition.partitionid = 1 + partition.name = "Test1" + partition.is_stay_armed = False + partition.is_fire_armed = False + partition.is_fire_enabled = False + partition.is_common_armed = False + partition.is_common_enabled = False + partition.is_locked = False + partition.is_new_partition = False + partition.is_night_stay_enabled = 0 + partition.exit_delay_timer = 0 + partition.arming_state = ArmingState.DISARMED + return partition + + +@pytest.fixture +def mock_partition_2() -> TotalConnectPartition: + """Create a mock TotalConnectPartition.""" + partition = AsyncMock(spec=TotalConnectPartition, autospec=True) + partition.partitionid = 2 + partition.name = "Test2" + partition.is_stay_armed = False + partition.is_fire_armed = False + partition.is_fire_enabled = False + partition.is_common_armed = False + partition.is_common_enabled = False + partition.is_locked = False + partition.is_new_partition = False + partition.is_night_stay_enabled = 0 + partition.exit_delay_timer = 0 + partition.arming_state = ArmingState.DISARMED + return partition + + +@pytest.fixture +def mock_location( + mock_partition: AsyncMock, mock_partition_2: AsyncMock +) -> TotalConnectLocation: + """Create a mock TotalConnectLocation.""" + location = AsyncMock(spec=TotalConnectLocation, autospec=True) + location.location_id = LOCATION_ID + location.location_name = "Test Location" + location.security_device_id = 7654321 + location.set_usercode.return_value = True + location.partitions = {1: mock_partition, 2: mock_partition_2} + location.devices = { + 7654321: TotalConnectDevice(load_json_object_fixture("device_1.json", DOMAIN)) + } + location.zones = { + z["ZoneID"]: create_mock_zone_from_dict(z) + for z in load_json_array_fixture("zones.json", DOMAIN) + } + location.is_low_battery.return_value = False + location.is_cover_tampered.return_value = False + location.is_ac_loss.return_value = False + location.arming_state = ArmingState.DISARMED + location._module_flags = { + "can_bypass_zones": True, + "can_clear_bypass": True, + "can_set_usercodes": True, + } + location.ac_loss = False + location.low_battery = False + location.auto_bypass_low_battery = False + location.cover_tampered = False + return location + + +@pytest.fixture +def mock_client(mock_location: TotalConnectLocation) -> Generator[TotalConnectClient]: + """Mock a TotalConnectClient for testing.""" + with ( + patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.totalconnect.TotalConnectClient", new=mock_client + ), + ): + client = mock_client.return_value + client.get_number_locations.return_value = 1 + client.locations = {mock_location.location_id: mock_location} + client.usercodes = {mock_location.location_id: CODE} + client.auto_bypass_low_battery = False + client._module_flags = {} + client.retry_delay = 0 + client._invalid_credentials = False + user_mock = AsyncMock(spec=TotalConnectUser, autospec=True) + user_mock._master_user = True + user_mock._user_admin = True + user_mock._config_admin = True + user_mock.security_problem.return_value = False + user_mock._features = { + "can_set_usercodes": True, + "can_bypass_zones": True, + "can_clear_bypass": True, + } + setattr(client, "_user", user_mock) + yield client + + +@pytest.fixture +def code_required() -> bool: + """Return whether a code is required.""" + return False + + +@pytest.fixture +def mock_config_entry(code_required: bool) -> MockConfigEntry: + """Create a mock config entry for testing.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_USERCODES: USERCODES, + }, + options={AUTO_BYPASS: False, CODE_REQUIRED: code_required}, + unique_id=USERNAME, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry for TotalConnect.""" + with patch( + "homeassistant.components.totalconnect.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/totalconnect/const.py b/tests/components/totalconnect/const.py new file mode 100644 index 00000000000..60024c21011 --- /dev/null +++ b/tests/components/totalconnect/const.py @@ -0,0 +1,8 @@ +"""Constants for the Total Connect tests.""" + +LOCATION_ID = 1234567 +CODE = "7890" + +USERNAME = "username@me.com" +PASSWORD = "password" +USERCODES = {LOCATION_ID: "7890"} diff --git a/tests/components/totalconnect/fixtures/device_1.json b/tests/components/totalconnect/fixtures/device_1.json new file mode 100644 index 00000000000..8ff17092a7d --- /dev/null +++ b/tests/components/totalconnect/fixtures/device_1.json @@ -0,0 +1,12 @@ +{ + "DeviceID": 7654321, + "DeviceName": "test", + "DeviceClassID": 1, + "DeviceSerialNumber": "1234567890AB", + "DeviceFlags": "PromptForUserCode=0,PromptForInstallerCode=0,PromptForImportSecuritySettings=0,AllowUserSlotEditing=0,CalCapable=1,CanBeSentToPanel=1,CanArmNightStay=0,CanSupportMultiPartition=0,PartitionCount=0,MaxPartitionCount=4,OnBoardingSupport=0,PartitionAdded=0,DuplicateUserSyncStatus=0,PanelType=12,PanelVariant=1,BLEDisarmCapable=0,ArmHomeSupported=1,DuplicateUserCodeCheck=1,CanSupportRapid=0,IsKeypadSupported=0,WifiEnrollmentSupported=1,IsConnectedPanel=1,ArmNightInSceneSupported=1,BuiltInCameraSettingsSupported=0,ZWaveThermostatScheduleDisabled=0,MultipleAuthorityLevelSupported=1,VideoOnPanelSupported=1,EnableBLEMode=0,IsPanelWiFiResetSupported=0,IsCompetitorClearBypass=0,IsNotReadyStateSupported=0,isArmStatusWithoutExitDelayNotSupported=0,UserCodeLength=4,UserCodeLengthChanged=0,DoubleDisarmRequired=0,TMSCloudSupported=0,IsAVCEnabled=0", + "SecurityPanelTypeID": 12, + "DeviceSerialText": null, + "DeviceType": null, + "DeviceVariants": null, + "RestrictedPanel": 0 +} diff --git a/tests/components/totalconnect/fixtures/zones.json b/tests/components/totalconnect/fixtures/zones.json new file mode 100644 index 00000000000..2bb237f976b --- /dev/null +++ b/tests/components/totalconnect/fixtures/zones.json @@ -0,0 +1,658 @@ +[ + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "020000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 2, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Security", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-11T09:00:13", + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "030000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 3, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Fire", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:41:05", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "040000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 4, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Gas", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-11T09:00:13", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "050000", + "LoopNumber": 1, + "ResponseType": "4", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 2 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 5, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Unknown", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:40:59", + "ZoneTypeId": 99 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "060000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 1, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 6, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Temperature", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 12 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 2, + "zoneAdditionalInfo": { + "SensorSerialNumber": "070000000000000A", + "LoopNumber": 2, + "ResponseType": "53", + "AlarmReportState": 0, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 15 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 7, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Doorbell Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 53 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "080000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 8, + "ZoneStatus": 1, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Office Side Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "090000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 9, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Office Back Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "100000", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 10, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Master Bedroom Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-06-02T15:40:57", + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "120000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 12, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Dining Room Two Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "130000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 13, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Patio Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "140000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 1 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 14, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Living Room Window", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "150000", + "LoopNumber": 1, + "ResponseType": "3", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 1 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 15, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Living Room Two Window", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 3 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "160000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 16, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Apartment SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:42:29", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "170000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 17, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Upstairs Hallway SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:53:57", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "180000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 18, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Downstairs Hallway SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:47:10", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "190000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 19, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Kid Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:49:07", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "200000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 20, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Guest Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:50:20", + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "210000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 21, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Apartment CarbonMonoxideDetecto", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:41:18", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "220000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 22, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Downstairs Hallway CarbonMonoxid", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:45:39", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "230000", + "LoopNumber": 1, + "ResponseType": "14", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 6 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 23, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Upstairs Hallway CarbonMonoxideD", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-04-28T09:52:37", + "ZoneTypeId": 14 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": { + "SensorSerialNumber": "240000", + "LoopNumber": 1, + "ResponseType": "9", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 4 + }, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 24, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Master Bedroom SmokeDetector", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 3, + "zoneAdditionalInfo": { + "SensorSerialNumber": "250000000000000A", + "LoopNumber": 1, + "ResponseType": "23", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 0, + "DeviceType": 15 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 25, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Garage Side Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": "2024-12-15T15:14:39", + "ZoneTypeId": 23 + }, + { + "PartitionId": 1, + "Batterylevel": 5, + "Signalstrength": 5, + "zoneAdditionalInfo": { + "SensorSerialNumber": "260000000000000A", + "LoopNumber": 1, + "ResponseType": "1", + "AlarmReportState": 1, + "ZoneSupervisionType": 0, + "ChimeState": 1, + "DeviceType": 0 + }, + "CanBeBypassed": 1, + "ZoneFlags": null, + "ZoneID": 26, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 1, + "ZoneDescription": "Front Door Door", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 1 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 800, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Master Bedroom Keypad", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 50 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1995, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 995 Fire", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 9 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1996, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 996 Medical", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 15 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1998, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 998 Other", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 6 + }, + { + "PartitionId": 1, + "Batterylevel": -1, + "Signalstrength": -1, + "zoneAdditionalInfo": null, + "CanBeBypassed": 0, + "ZoneFlags": null, + "ZoneID": 1999, + "ZoneStatus": 0, + "IsBypassableZone": 0, + "IsSensingZone": 0, + "ZoneDescription": "Zone 999 Police", + "AlarmTriggerTime": null, + "AlarmTriggerTimeLocalized": null, + "ZoneTypeId": 7 + } +] diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index 174ab96e8dc..a79fe3832cd 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_attributes[alarm_control_panel.test-entry] +# name: test_entities[alarm_control_panel.test-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30,11 +30,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '123456', + 'unique_id': '1234567', 'unit_of_measurement': None, }) # --- -# name: test_attributes[alarm_control_panel.test-state] +# name: test_entities[alarm_control_panel.test-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, @@ -51,7 +51,7 @@ 'state': 'disarmed', }) # --- -# name: test_attributes[alarm_control_panel.test_partition_2-entry] +# name: test_entities[alarm_control_panel.test_partition_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -82,11 +82,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'partition', - 'unique_id': '123456_2', + 'unique_id': '1234567_2', 'unit_of_measurement': None, }) # --- -# name: test_attributes[alarm_control_panel.test_partition_2-state] +# name: test_entities[alarm_control_panel.test_partition_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'changed_by': None, diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index 75aaddf8572..55702b06acc 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -1,4 +1,940 @@ # serializer version: 1 +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto-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.apartment_carbonmonoxidedetecto', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Apartment CarbonMonoxideDetecto', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_battery-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.apartment_carbonmonoxidedetecto_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Apartment CarbonMonoxideDetecto Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_tamper-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.apartment_carbonmonoxidedetecto_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_21_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_carbonmonoxidedetecto_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Apartment CarbonMonoxideDetecto Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '21', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_carbonmonoxidedetecto_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector-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.apartment_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Apartment SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_battery-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.apartment_smokedetector_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Apartment SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_tamper-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.apartment_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_16_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.apartment_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Apartment SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '16', + }), + 'context': , + 'entity_id': 'binary_sensor.apartment_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_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.dining_room_two_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Dining Room Two Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_battery-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.dining_room_two_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Dining Room Two Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_tamper-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.dining_room_two_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_12_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.dining_room_two_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Dining Room Two Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '12', + }), + 'context': , + 'entity_id': 'binary_sensor.dining_room_two_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other-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.doorbell_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Doorbell Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_battery-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.doorbell_other_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Doorbell Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_tamper-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.doorbell_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_7_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.doorbell_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Doorbell Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '7', + }), + 'context': , + 'entity_id': 'binary_sensor.doorbell_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid-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.downstairs_hallway_carbonmonoxid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_battery-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.downstairs_hallway_carbonmonoxid_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_tamper-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.downstairs_hallway_carbonmonoxid_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_22_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_carbonmonoxid_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Downstairs Hallway CarbonMonoxid Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '22', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_carbonmonoxid_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector-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.downstairs_hallway_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Downstairs Hallway SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_battery-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.downstairs_hallway_smokedetector_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Downstairs Hallway SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_tamper-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.downstairs_hallway_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_18_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.downstairs_hallway_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Downstairs Hallway SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '18', + }), + 'context': , + 'entity_id': 'binary_sensor.downstairs_hallway_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entity_registry[binary_sensor.fire-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -30,7 +966,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_zone', + 'unique_id': '1234567_3_zone', 'unit_of_measurement': None, }) # --- @@ -39,16 +975,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'Fire', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.fire_battery-entry] @@ -82,7 +1018,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_low_battery', + 'unique_id': '1234567_3_low_battery', 'unit_of_measurement': None, }) # --- @@ -91,9 +1027,9 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Fire Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire_battery', @@ -134,7 +1070,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_2_tamper', + 'unique_id': '1234567_3_tamper', 'unit_of_measurement': None, }) # --- @@ -143,16 +1079,328 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Fire Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '2', + 'zone_id': '3', }), 'context': , 'entity_id': 'binary_sensor.fire_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_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.front_door_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Front Door Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_battery-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.front_door_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_tamper-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.front_door_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_26_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.front_door_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Front Door Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '26', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other-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.garage_side_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Garage Side Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_battery-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.garage_side_other_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Garage Side Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_tamper-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.garage_side_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_25_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.garage_side_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Garage Side Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '25', + }), + 'context': , + 'entity_id': 'binary_sensor.garage_side_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas-entry] @@ -178,7 +1426,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -186,25 +1434,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_zone', + 'unique_id': '1234567_4_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.gas-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'gas', + 'device_class': 'smoke', 'friendly_name': 'Gas', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas_battery-entry] @@ -238,7 +1486,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_low_battery', + 'unique_id': '1234567_4_low_battery', 'unit_of_measurement': None, }) # --- @@ -247,16 +1495,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Gas Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.gas_tamper-entry] @@ -290,7 +1538,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_3_tamper', + 'unique_id': '1234567_4_tamper', 'unit_of_measurement': None, }) # --- @@ -299,9 +1547,9 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Gas Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '3', + 'zone_id': '4', }), 'context': , 'entity_id': 'binary_sensor.gas_tamper', @@ -311,7 +1559,7 @@ 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.medical-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -324,7 +1572,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.medical', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -334,7 +1582,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -342,80 +1590,28 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_5_zone', + 'unique_id': '1234567_20_zone', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.medical-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'safety', - 'friendly_name': 'Medical', - 'location_id': 123456, + 'device_class': 'smoke', + 'friendly_name': 'Guest Bedroom SmokeDetector', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '5', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.medical', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.motion-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.motion', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'totalconnect', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '123456_4_zone', - 'unit_of_measurement': None, - }) -# --- -# name: test_entity_registry[binary_sensor.motion-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'motion', - 'friendly_name': 'Motion', - 'location_id': 123456, - 'partition': '1', - 'zone_id': '4', - }), - 'context': , - 'entity_id': 'binary_sensor.motion', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_entity_registry[binary_sensor.motion_battery-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -428,7 +1624,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.motion_battery', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -446,28 +1642,28 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_4_low_battery', + 'unique_id': '1234567_20_low_battery', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.motion_battery-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Motion Battery', - 'location_id': 123456, + 'friendly_name': 'Guest Bedroom SmokeDetector Battery', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '4', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.motion_battery', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- -# name: test_entity_registry[binary_sensor.motion_tamper-entry] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_tamper-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -480,7 +1676,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.motion_tamper', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_tamper', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -498,25 +1694,1429 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_4_tamper', + 'unique_id': '1234567_20_tamper', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[binary_sensor.motion_tamper-state] +# name: test_entity_registry[binary_sensor.guest_bedroom_smokedetector_tamper-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', - 'friendly_name': 'Motion Tamper', - 'location_id': 123456, + 'friendly_name': 'Guest Bedroom SmokeDetector Tamper', + 'location_id': 1234567, 'partition': '1', - 'zone_id': '4', + 'zone_id': '20', }), 'context': , - 'entity_id': 'binary_sensor.motion_tamper', + 'entity_id': 'binary_sensor.guest_bedroom_smokedetector_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector-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.kid_bedroom_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Kid Bedroom SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_battery-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.kid_bedroom_smokedetector_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kid Bedroom SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_tamper-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.kid_bedroom_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_19_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.kid_bedroom_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Kid Bedroom SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '19', + }), + 'context': , + 'entity_id': 'binary_sensor.kid_bedroom_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window-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.living_room_two_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Living Room Two Window', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_battery-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.living_room_two_window_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Two Window Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_tamper-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.living_room_two_window_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_15_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_two_window_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Living Room Two Window Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '15', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_two_window_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window-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.living_room_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Living Room Window', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_battery-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.living_room_window_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Living Room Window Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_tamper-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.living_room_window_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_14_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.living_room_window_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Living Room Window Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '14', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_window_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_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.master_bedroom_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_battery-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.master_bedroom_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_tamper-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.master_bedroom_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_10_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '10', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad-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.master_bedroom_keypad', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom Keypad', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_battery-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.master_bedroom_keypad_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom Keypad Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_tamper-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.master_bedroom_keypad_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_800_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_keypad_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom Keypad Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '800', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_keypad_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector-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.master_bedroom_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Master Bedroom SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_battery-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.master_bedroom_smokedetector_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bedroom SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_tamper-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.master_bedroom_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_24_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.master_bedroom_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Master Bedroom SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '24', + }), + 'context': , + 'entity_id': 'binary_sensor.master_bedroom_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_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.office_back_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Office Back Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_battery-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.office_back_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Back Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_tamper-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.office_back_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_9_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_back_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Office Back Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '9', + }), + 'context': , + 'entity_id': 'binary_sensor.office_back_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_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.office_side_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Office Side Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_battery-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.office_side_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Office Side Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_tamper-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.office_side_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_8_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.office_side_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Office Side Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '8', + }), + 'context': , + 'entity_id': 'binary_sensor.office_side_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_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.patio_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': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Patio Door', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_battery-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.patio_door_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Patio Door Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_tamper-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.patio_door_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_13_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.patio_door_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Patio Door Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '13', + }), + 'context': , + 'entity_id': 'binary_sensor.patio_door_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.security-entry] @@ -542,7 +3142,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -550,18 +3150,18 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_zone', + 'unique_id': '1234567_2_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.security-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', + 'device_class': 'smoke', 'friendly_name': 'Security', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security', @@ -602,7 +3202,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_low_battery', + 'unique_id': '1234567_2_low_battery', 'unit_of_measurement': None, }) # --- @@ -611,16 +3211,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Security Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.security_tamper-entry] @@ -654,7 +3254,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_1_tamper', + 'unique_id': '1234567_2_tamper', 'unit_of_measurement': None, }) # --- @@ -663,16 +3263,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Security Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '1', + 'zone_id': '2', }), 'context': , 'entity_id': 'binary_sensor.security_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature-entry] @@ -698,7 +3298,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -706,25 +3306,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_zone', + 'unique_id': '1234567_6_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'problem', + 'device_class': 'smoke', 'friendly_name': 'Temperature', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature_battery-entry] @@ -758,7 +3358,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_low_battery', + 'unique_id': '1234567_6_low_battery', 'unit_of_measurement': None, }) # --- @@ -767,16 +3367,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Temperature Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.temperature_tamper-entry] @@ -810,7 +3410,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_7_tamper', + 'unique_id': '1234567_6_tamper', 'unit_of_measurement': None, }) # --- @@ -819,16 +3419,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Temperature Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': 7, + 'zone_id': '6', }), 'context': , 'entity_id': 'binary_sensor.temperature_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.test_battery-entry] @@ -862,7 +3462,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_low_battery', + 'unique_id': '1234567_low_battery', 'unit_of_measurement': None, }) # --- @@ -871,7 +3471,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'test Battery', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_battery', @@ -912,7 +3512,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_carbon_monoxide', + 'unique_id': '1234567_carbon_monoxide', 'unit_of_measurement': None, }) # --- @@ -921,7 +3521,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'carbon_monoxide', 'friendly_name': 'test Carbon monoxide', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_carbon_monoxide', @@ -962,7 +3562,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'police', - 'unique_id': '123456_police', + 'unique_id': '1234567_police', 'unit_of_measurement': None, }) # --- @@ -970,7 +3570,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'test Police emergency', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_police_emergency', @@ -1011,7 +3611,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_power', + 'unique_id': '1234567_power', 'unit_of_measurement': None, }) # --- @@ -1020,7 +3620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'test Power', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_power', @@ -1061,7 +3661,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_smoke', + 'unique_id': '1234567_smoke', 'unit_of_measurement': None, }) # --- @@ -1070,7 +3670,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'test Smoke', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_smoke', @@ -1111,7 +3711,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_tamper', + 'unique_id': '1234567_tamper', 'unit_of_measurement': None, }) # --- @@ -1120,7 +3720,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'test Tamper', - 'location_id': 123456, + 'location_id': 1234567, }), 'context': , 'entity_id': 'binary_sensor.test_tamper', @@ -1153,7 +3753,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'totalconnect', @@ -1161,25 +3761,25 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_zone', + 'unique_id': '1234567_5_zone', 'unit_of_measurement': None, }) # --- # name: test_entity_registry[binary_sensor.unknown-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', + 'device_class': 'smoke', 'friendly_name': 'Unknown', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.unknown_battery-entry] @@ -1213,7 +3813,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_low_battery', + 'unique_id': '1234567_5_low_battery', 'unit_of_measurement': None, }) # --- @@ -1222,16 +3822,16 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Unknown Battery', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown_battery', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_entity_registry[binary_sensor.unknown_tamper-entry] @@ -1265,7 +3865,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456_6_tamper', + 'unique_id': '1234567_5_tamper', 'unit_of_measurement': None, }) # --- @@ -1274,15 +3874,951 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Unknown Tamper', - 'location_id': 123456, + 'location_id': 1234567, 'partition': '1', - 'zone_id': '6', + 'zone_id': '5', }), 'context': , 'entity_id': 'binary_sensor.unknown_tamper', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided-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.upstairs_hallway_carbonmonoxided', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_battery-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.upstairs_hallway_carbonmonoxided_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_tamper-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.upstairs_hallway_carbonmonoxided_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_23_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_carbonmonoxided_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Upstairs Hallway CarbonMonoxideD Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '23', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_carbonmonoxided_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector-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.upstairs_hallway_smokedetector', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Upstairs Hallway SmokeDetector', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_battery-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.upstairs_hallway_smokedetector_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Upstairs Hallway SmokeDetector Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_tamper-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.upstairs_hallway_smokedetector_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_17_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.upstairs_hallway_smokedetector_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Upstairs Hallway SmokeDetector Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '17', + }), + 'context': , + 'entity_id': 'binary_sensor.upstairs_hallway_smokedetector_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire-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.zone_995_fire', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 995 Fire', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_battery-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.zone_995_fire_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 995 Fire Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_tamper-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.zone_995_fire_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1995_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_995_fire_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 995 Fire Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1995', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_995_fire_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical-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.zone_996_medical', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 996 Medical', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_battery-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.zone_996_medical_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 996 Medical Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_tamper-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.zone_996_medical_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1996_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_996_medical_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 996 Medical Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1996', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_996_medical_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other-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.zone_998_other', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 998 Other', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_battery-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.zone_998_other_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 998 Other Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_tamper-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.zone_998_other_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1998_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_998_other_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 998 Other Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1998', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_998_other_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police-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.zone_999_police', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Zone 999 Police', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_battery-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.zone_999_police_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': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone 999 Police Battery', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_tamper-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.zone_999_police_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567_1999_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[binary_sensor.zone_999_police_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Zone 999 Police Tamper', + 'location_id': 1234567, + 'partition': '1', + 'zone_id': '1999', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_999_police_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', }) # --- diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr index 4367b035cc8..db90af349cb 100644 --- a/tests/components/totalconnect/snapshots/test_button.ambr +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -1,4 +1,100 @@ # serializer version: 1 +# name: test_entity_registry[button.dining_room_two_door_bypass-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': , + 'entity_id': 'button.dining_room_two_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_12_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.dining_room_two_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dining Room Two Door Bypass', + }), + 'context': , + 'entity_id': 'button.dining_room_two_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.doorbell_other_bypass-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': , + 'entity_id': 'button.doorbell_other_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_7_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.doorbell_other_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Doorbell Other Bypass', + }), + 'context': , + 'entity_id': 'button.doorbell_other_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.fire_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -30,7 +126,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_2_bypass', + 'unique_id': '1234567_3_bypass', 'unit_of_measurement': None, }) # --- @@ -47,6 +143,102 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.front_door_door_bypass-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': , + 'entity_id': 'button.front_door_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_26_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.front_door_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Front Door Door Bypass', + }), + 'context': , + 'entity_id': 'button.front_door_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.garage_side_other_bypass-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': , + 'entity_id': 'button.garage_side_other_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_25_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.garage_side_other_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garage Side Other Bypass', + }), + 'context': , + 'entity_id': 'button.garage_side_other_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.gas_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,7 +270,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_3_bypass', + 'unique_id': '1234567_4_bypass', 'unit_of_measurement': None, }) # --- @@ -95,7 +287,7 @@ 'state': 'unknown', }) # --- -# name: test_entity_registry[button.motion_bypass-entry] +# name: test_entity_registry[button.living_room_two_window_bypass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +300,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.motion_bypass', + 'entity_id': 'button.living_room_two_window_bypass', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -126,17 +318,257 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_4_bypass', + 'unique_id': '1234567_15_bypass', 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[button.motion_bypass-state] +# name: test_entity_registry[button.living_room_two_window_bypass-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Motion Bypass', + 'friendly_name': 'Living Room Two Window Bypass', }), 'context': , - 'entity_id': 'button.motion_bypass', + 'entity_id': 'button.living_room_two_window_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.living_room_window_bypass-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': , + 'entity_id': 'button.living_room_window_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_14_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.living_room_window_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Window Bypass', + }), + 'context': , + 'entity_id': 'button.living_room_window_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.master_bedroom_door_bypass-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': , + 'entity_id': 'button.master_bedroom_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_10_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.master_bedroom_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Master Bedroom Door Bypass', + }), + 'context': , + 'entity_id': 'button.master_bedroom_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.office_back_door_bypass-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': , + 'entity_id': 'button.office_back_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_9_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.office_back_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Back Door Bypass', + }), + 'context': , + 'entity_id': 'button.office_back_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.office_side_door_bypass-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': , + 'entity_id': 'button.office_side_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_8_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.office_side_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Side Door Bypass', + }), + 'context': , + 'entity_id': 'button.office_side_door_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.patio_door_bypass-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': , + 'entity_id': 'button.patio_door_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_13_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.patio_door_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Patio Door Bypass', + }), + 'context': , + 'entity_id': 'button.patio_door_bypass', 'last_changed': , 'last_reported': , 'last_updated': , @@ -174,7 +606,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass', - 'unique_id': '123456_1_bypass', + 'unique_id': '1234567_2_bypass', 'unit_of_measurement': None, }) # --- @@ -191,6 +623,54 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.temperature_bypass-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': , + 'entity_id': 'button.temperature_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_6_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.temperature_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Temperature Bypass', + }), + 'context': , + 'entity_id': 'button.temperature_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entity_registry[button.test_bypass_all-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -222,7 +702,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'bypass_all', - 'unique_id': '123456_bypass_all', + 'unique_id': '1234567_bypass_all', 'unit_of_measurement': None, }) # --- @@ -270,7 +750,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'clear_bypass', - 'unique_id': '123456_clear_bypass', + 'unique_id': '1234567_clear_bypass', 'unit_of_measurement': None, }) # --- @@ -287,3 +767,51 @@ 'state': 'unknown', }) # --- +# name: test_entity_registry[button.unknown_bypass-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': , + 'entity_id': 'button.unknown_bypass', + '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': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '1234567_5_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.unknown_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Unknown Bypass', + }), + 'context': , + 'entity_id': 'button.unknown_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/totalconnect/snapshots/test_diagnostics.ambr b/tests/components/totalconnect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..026afca0920 --- /dev/null +++ b/tests/components/totalconnect/snapshots/test_diagnostics.ambr @@ -0,0 +1,619 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'client': dict({ + 'auto_bypass_low_battery': False, + 'invalid_credentials': False, + 'module_flags': dict({ + }), + 'retry_delay': 0, + }), + 'locations': list([ + dict({ + 'ac_loss': False, + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'auto_bypass_low_battery': False, + 'cover_tampered': False, + 'devices': list([ + dict({ + 'class_id': 1, + 'device_id': 7654321, + 'flags': dict({ + 'AllowUserSlotEditing': '0', + 'ArmHomeSupported': '1', + 'ArmNightInSceneSupported': '1', + 'BLEDisarmCapable': '0', + 'BuiltInCameraSettingsSupported': '0', + 'CalCapable': '1', + 'CanArmNightStay': '0', + 'CanBeSentToPanel': '1', + 'CanSupportMultiPartition': '0', + 'CanSupportRapid': '0', + 'DoubleDisarmRequired': '0', + 'DuplicateUserCodeCheck': '1', + 'DuplicateUserSyncStatus': '0', + 'EnableBLEMode': '0', + 'IsAVCEnabled': '0', + 'IsCompetitorClearBypass': '0', + 'IsConnectedPanel': '1', + 'IsKeypadSupported': '0', + 'IsNotReadyStateSupported': '0', + 'IsPanelWiFiResetSupported': '0', + 'MaxPartitionCount': '4', + 'MultipleAuthorityLevelSupported': '1', + 'OnBoardingSupport': '0', + 'PanelType': '12', + 'PanelVariant': '1', + 'PartitionAdded': '0', + 'PartitionCount': '0', + 'PromptForImportSecuritySettings': '0', + 'PromptForInstallerCode': '0', + 'PromptForUserCode': '0', + 'TMSCloudSupported': '0', + 'UserCodeLength': '4', + 'UserCodeLengthChanged': '0', + 'VideoOnPanelSupported': '1', + 'WifiEnrollmentSupported': '1', + 'ZWaveThermostatScheduleDisabled': '0', + 'isArmStatusWithoutExitDelayNotSupported': '0', + }), + 'name': 'test', + 'security_panel_type_id': 12, + 'serial_number': '**REDACTED**', + 'serial_text': None, + }), + ]), + 'location_id': 1234567, + 'low_battery': False, + 'module_flags': dict({ + 'can_bypass_zones': True, + 'can_clear_bypass': True, + 'can_set_usercodes': True, + }), + 'name': 'Test Location', + 'partitions': list([ + dict({ + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'exit_delay_timer': 0, + 'is_common_enabled': False, + 'is_fire_enabled': False, + 'is_locked': False, + 'is_new_partition': False, + 'is_night_stay_enabled': 0, + 'is_stay_armed': False, + 'name': 'Test1', + 'partition_id': 1, + }), + dict({ + 'arming_state': dict({ + '__type': "", + 'repr': '', + }), + 'exit_delay_timer': 0, + 'is_common_enabled': False, + 'is_fire_enabled': False, + 'is_locked': False, + 'is_new_partition': False, + 'is_night_stay_enabled': 0, + 'is_stay_armed': False, + 'name': 'Test2', + 'partition_id': 2, + }), + ]), + 'security_device_id': 7654321, + 'zones': list([ + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Security', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 2, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Fire', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 3, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Gas', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 4, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Unknown', + 'device_type': 2, + 'loop_number': 1, + 'partition': 1, + 'response_type': '4', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 5, + 'zone_type_id': 99, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Temperature', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 1, + 'zone_id': 6, + 'zone_type_id': 12, + }), + dict({ + 'alarm_report_state': 0, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Doorbell Other', + 'device_type': 15, + 'loop_number': 2, + 'partition': 1, + 'response_type': '53', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 2, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 7, + 'zone_type_id': 53, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Office Side Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 1, + 'supervision_type': 0, + 'zone_id': 8, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Office Back Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 9, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Master Bedroom Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 10, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Dining Room Two Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 12, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Patio Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 13, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Living Room Window', + 'device_type': 1, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 14, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Living Room Two Window', + 'device_type': 1, + 'loop_number': 1, + 'partition': 1, + 'response_type': '3', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 15, + 'zone_type_id': 3, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Apartment SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 16, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Upstairs Hallway SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 17, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Downstairs Hallway SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 18, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Kid Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 19, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Guest Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 20, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Apartment CarbonMonoxideDetecto', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 21, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Downstairs Hallway CarbonMonoxid', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 22, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Upstairs Hallway CarbonMonoxideD', + 'device_type': 6, + 'loop_number': 1, + 'partition': 1, + 'response_type': '14', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 23, + 'zone_type_id': 14, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': 0, + 'description': 'Master Bedroom SmokeDetector', + 'device_type': 4, + 'loop_number': 1, + 'partition': 1, + 'response_type': '9', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': -1, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 24, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 0, + 'description': 'Garage Side Other', + 'device_type': 15, + 'loop_number': 1, + 'partition': 1, + 'response_type': '23', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 3, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 25, + 'zone_type_id': 23, + }), + dict({ + 'alarm_report_state': 1, + 'battery_level': 5, + 'can_be_bypassed': 1, + 'chime_state': 1, + 'description': 'Front Door Door', + 'device_type': 0, + 'loop_number': 1, + 'partition': 1, + 'response_type': '1', + 'sensor_serial_number': '**REDACTED**', + 'signal_strength': 5, + 'status': 0, + 'supervision_type': 0, + 'zone_id': 26, + 'zone_type_id': 1, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Master Bedroom Keypad', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 800, + 'zone_type_id': 50, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 995 Fire', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1995, + 'zone_type_id': 9, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 996 Medical', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1996, + 'zone_type_id': 15, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 998 Other', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1998, + 'zone_type_id': 6, + }), + dict({ + 'alarm_report_state': None, + 'battery_level': -1, + 'can_be_bypassed': 0, + 'chime_state': None, + 'description': 'Zone 999 Police', + 'device_type': None, + 'loop_number': None, + 'partition': 1, + 'response_type': None, + 'sensor_serial_number': None, + 'signal_strength': -1, + 'status': 0, + 'supervision_type': None, + 'zone_id': 1999, + 'zone_type_id': 7, + }), + ]), + }), + ]), + 'user': dict({ + 'config_admin': True, + 'features': dict({ + 'can_bypass_zones': True, + 'can_clear_bypass': True, + 'can_set_usercodes': True, + }), + 'master': True, + 'security_problem': False, + 'user_admin': True, + }), + }) +# --- diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 6f7d8163362..040cdf5d9ed 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -1,19 +1,16 @@ """Tests for the TotalConnect alarm control panel device.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from total_connect_client.exceptions import ( - AuthenticationError, - ServiceUnavailable, - TotalConnectError, -) +from total_connect_client import ArmingState, ArmType +from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid from homeassistant.components.alarm_control_panel import ( - DOMAIN as ALARM_DOMAIN, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelState, ) from homeassistant.components.totalconnect.alarm_control_panel import ( @@ -21,593 +18,375 @@ from homeassistant.components.totalconnect.alarm_control_panel import ( SERVICE_ALARM_ARM_HOME_INSTANT, ) from homeassistant.components.totalconnect.const import DOMAIN -from homeassistant.components.totalconnect.coordinator import SCAN_INTERVAL -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, - STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import async_update_entity -from .common import ( - LOCATION_ID, - RESPONSE_ARM_FAILURE, - RESPONSE_ARM_SUCCESS, - RESPONSE_ARMED_AWAY, - RESPONSE_ARMED_CUSTOM, - RESPONSE_ARMED_NIGHT, - RESPONSE_ARMED_STAY, - RESPONSE_ARMING, - RESPONSE_DISARM_FAILURE, - RESPONSE_DISARM_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMING, - RESPONSE_SUCCESS, - RESPONSE_UNKNOWN, - RESPONSE_USER_CODE_INVALID, - TOTALCONNECT_REQUEST, - USERCODES, - setup_platform, -) +from . import setup_integration +from .const import CODE -from tests.common import async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "alarm_control_panel.test" ENTITY_ID_2 = "alarm_control_panel.test_partition_2" -CODE = "-1" DATA = {ATTR_ENTITY_ID: ENTITY_ID} DELAY = timedelta(seconds=10) +ARMING_HELPER = "homeassistant.components.totalconnect.alarm_control_panel.ArmingHelper" -async def test_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the alarm control panel attributes are correct.""" - entry = await setup_platform(hass, ALARM_DOMAIN) with patch( - "homeassistant.components.totalconnect.TotalConnectClient.request", - return_value=RESPONSE_DISARMED, - ) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - mock_request.assert_called_once() + "homeassistant.components.totalconnect.PLATFORMS", + [Platform.ALARM_CONTROL_PANEL], + ): + await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) - assert mock_request.call_count == 1 + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_arm_home_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [False, True]) +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME, ArmType.STAY), + (SERVICE_ALARM_ARM_NIGHT, ArmType.STAY_NIGHT), + (SERVICE_ALARM_ARM_AWAY, ArmType.AWAY), + ], +) +async def test_arming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test arm home method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_HOME - # second partition should not be armed - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, + ) + assert mock_partition.arm.call_args[1] == {"arm_type": arm_type, "usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_arm_home_failure(hass: HomeAssistant) -> None: - """Test arm home method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm home test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # config entry usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm home" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_home_instant_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [True]) +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME, ArmType.STAY), + (SERVICE_ALARM_ARM_NIGHT, ArmType.STAY_NIGHT), + (SERVICE_ALARM_ARM_AWAY, ArmType.AWAY), + ], +) +async def test_arming_invalid_usercode( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test arm home instant method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method with invalid usercode.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(ServiceValidationError, match="Incorrect code entered"): await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: "invalid_code"}, + blocking=True, ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_HOME + assert mock_partition.arm.call_count == 0 + assert mock_location.get_panel_meta_data.call_count == 1 -async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: - """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm home instant test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert str(err.value) == "Usercode is invalid, did not arm home instant" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_away_instant_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [False, True]) +async def test_disarming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test arm home instant method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert hass.states.get(ENTITY_ID_2).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test disarming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, + ) + assert mock_partition.disarm.call_args[1] == {"usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: - """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm away instant test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm away instant" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arm_away_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize("code_required", [True]) +async def test_disarming_invalid_usercode( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test arm away method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test disarming method with invalid usercode.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(ServiceValidationError, match="Incorrect code entered"): await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: "invalid_code"}, + blocking=True, ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY + assert mock_partition.disarm.call_count == 0 + assert mock_location.get_panel_meta_data.call_count == 1 -async def test_arm_away_failure(hass: HomeAssistant) -> None: - """Test arm away method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm away test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm away" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_disarm_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("service", "arm_type"), + [ + (SERVICE_ALARM_ARM_HOME_INSTANT, ArmType.STAY_INSTANT), + (SERVICE_ALARM_ARM_AWAY_INSTANT, ArmType.AWAY_INSTANT), + ], +) +async def test_instant_arming( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + arm_type: ArmType, ) -> None: - """Test disarm method success.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 + """Test instant arming method success.""" + await setup_integration(hass, mock_config_entry) - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED + mock_partition.arming_state = ArmingState.ARMING + + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_partition.arm.call_args[1] == {"arm_type": arm_type, "usercode": ""} + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING -async def test_disarm_failure(hass: HomeAssistant) -> None: - """Test disarm method failure.""" - responses = [ - RESPONSE_ARMED_AWAY, - RESPONSE_DISARM_FAILURE, - RESPONSE_USER_CODE_INVALID, - ] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to disarm test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not disarm" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_disarm_code_required( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("exception", "suffix", "flows"), + [(UsercodeInvalid, "invalid_code", 1), (BadResultCodeError, "failed", 0)], +) +@pytest.mark.parametrize( + ("service", "prefix"), + [ + (SERVICE_ALARM_ARM_HOME, "arm_home"), + (SERVICE_ALARM_ARM_NIGHT, "arm_night"), + (SERVICE_ALARM_ARM_AWAY, "arm_away"), + ], +) +async def test_arming_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + prefix: str, + exception: Exception, + suffix: str, + flows: int, ) -> None: - """Test disarm with code.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] - await setup_platform(hass, ALARM_DOMAIN, code_required=True) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 + """Test arming method exceptions.""" + await setup_integration(hass, mock_config_entry) - # runtime user entered code is bad - DATA_WITH_CODE = DATA.copy() - DATA_WITH_CODE["code"] = "666" - with pytest.raises(ServiceValidationError, match="Incorrect code entered"): - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True - ) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - # code check means the call to total_connect never happens - assert mock_request.call_count == 1 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 - # runtime user entered code that is in config - DATA_WITH_CODE["code"] = USERCODES[LOCATION_ID] + mock_partition.arm.side_effect = exception + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(HomeAssistantError) as exc: await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True + ALARM_CONTROL_PANEL_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, ATTR_CODE: CODE}, + blocking=True, ) - await hass.async_block_till_done() - assert mock_request.call_count == 2 + assert mock_partition.arm.call_count == 1 - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED + assert exc.value.translation_key == f"{prefix}_{suffix}" + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == flows -async def test_arm_night_success( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("exception", "suffix", "flows"), + [(UsercodeInvalid, "invalid_code", 1), (BadResultCodeError, "failed", 0)], +) +@pytest.mark.parametrize( + ("service", "prefix"), + [ + (SERVICE_ALARM_ARM_HOME_INSTANT, "arm_home_instant"), + (SERVICE_ALARM_ARM_AWAY_INSTANT, "arm_away_instant"), + ], +) +async def test_instant_arming_exceptions( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + prefix: str, + exception: Exception, + suffix: str, + flows: int, ) -> None: - """Test arm night method success.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_NIGHT] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming method exceptions.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + mock_partition.arm.side_effect = exception + + mock_partition.arming_state = ArmingState.ARMING + + with pytest.raises(HomeAssistantError) as exc: await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) - assert mock_request.call_count == 2 + assert mock_partition.arm.call_count == 1 - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_NIGHT + assert exc.value.translation_key == f"{prefix}_{suffix}" + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + assert mock_location.get_panel_meta_data.call_count == 1 + + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == flows -async def test_arm_night_failure(hass: HomeAssistant) -> None: - """Test arm night method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Failed to arm night test" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 2 - - # usercode is invalid - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - await hass.async_block_till_done() - assert f"{err.value}" == "Usercode is invalid, did not arm night" - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - # should have started a re-auth flow - assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 - assert mock_request.call_count == 3 - - -async def test_arming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test arming.""" - responses = [RESPONSE_DISARMED, RESPONSE_SUCCESS, RESPONSE_ARMING] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 - - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True - ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMING - - -async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Test disarming.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_SUCCESS, RESPONSE_DISARMING] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.ARMED_AWAY - assert mock_request.call_count == 1 - - await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True - ) - assert mock_request.call_count == 2 - - freezer.tick(DELAY) - async_fire_time_changed(hass) - await hass.async_block_till_done() - assert mock_request.call_count == 3 - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMING - - -async def test_armed_custom(hass: HomeAssistant) -> None: - """Test armed custom.""" - responses = [RESPONSE_ARMED_CUSTOM] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert ( - hass.states.get(ENTITY_ID).state - == AlarmControlPanelState.ARMED_CUSTOM_BYPASS - ) - assert mock_request.call_count == 1 - - -async def test_unknown(hass: HomeAssistant) -> None: - """Test unknown arm status.""" - responses = [RESPONSE_UNKNOWN] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 1 - - -async def test_other_update_failures( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("arming_state", "state"), + [ + (ArmingState.DISARMED, AlarmControlPanelState.DISARMED), + (ArmingState.DISARMED_BYPASS, AlarmControlPanelState.DISARMED), + (ArmingState.DISARMED_ZONE_FAULTED, AlarmControlPanelState.DISARMED), + (ArmingState.ARMED_STAY_NIGHT, AlarmControlPanelState.ARMED_NIGHT), + (ArmingState.ARMED_STAY_NIGHT_BYPASS_PROA7, AlarmControlPanelState.ARMED_NIGHT), + ( + ArmingState.ARMED_STAY_NIGHT_INSTANT_PROA7, + AlarmControlPanelState.ARMED_NIGHT, + ), + ( + ArmingState.ARMED_STAY_NIGHT_INSTANT_BYPASS_PROA7, + AlarmControlPanelState.ARMED_NIGHT, + ), + (ArmingState.ARMED_STAY, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_BYPASS, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_BYPASS_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_PROA7, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_STAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_HOME), + ( + ArmingState.ARMED_STAY_INSTANT_BYPASS_PROA7, + AlarmControlPanelState.ARMED_HOME, + ), + (ArmingState.ARMED_STAY_OTHER, AlarmControlPanelState.ARMED_HOME), + (ArmingState.ARMED_AWAY, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_BYPASS, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_INSTANT, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_AWAY_INSTANT_BYPASS, AlarmControlPanelState.ARMED_AWAY), + (ArmingState.ARMED_CUSTOM_BYPASS, AlarmControlPanelState.ARMED_CUSTOM_BYPASS), + (ArmingState.ARMING, AlarmControlPanelState.ARMING), + (ArmingState.DISARMING, AlarmControlPanelState.DISARMING), + (ArmingState.ALARMING, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_FIRE_SMOKE, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_CARBON_MONOXIDE, AlarmControlPanelState.TRIGGERED), + (ArmingState.ALARMING_CARBON_MONOXIDE_PROA7, AlarmControlPanelState.TRIGGERED), + ], +) +async def test_arming_state( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_partition: AsyncMock, + mock_location: AsyncMock, + mock_config_entry: MockConfigEntry, + arming_state: ArmingState, + state: AlarmControlPanelState, + freezer: FrozenDateTimeFactory, ) -> None: - """Test other failures seen during updates.""" - responses = [ - RESPONSE_DISARMED, - ServiceUnavailable, - RESPONSE_DISARMED, - TotalConnectError, - RESPONSE_DISARMED, - ValueError, - ] - await setup_platform(hass, ALARM_DOMAIN) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - # first things work as planned - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 1 + """Test arming state transitions.""" + await setup_integration(hass, mock_config_entry) - # then an error: ServiceUnavailable --> UpdateFailed - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 2 + entity_id = "alarm_control_panel.test" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED - # works again - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 3 + mock_partition.arming_state = arming_state - # then an error: TotalConnectError --> UpdateFailed - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 4 + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - # works again - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == AlarmControlPanelState.DISARMED - assert mock_request.call_count == 5 - - # unknown TotalConnect status via ValueError - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - assert mock_request.call_count == 6 - - -async def test_authentication_error(hass: HomeAssistant) -> None: - """Test other failures seen during updates.""" - entry = await setup_platform(hass, ALARM_DOMAIN) - - with patch(TOTALCONNECT_REQUEST, side_effect=AuthenticationError): - await async_update_entity(hass, ENTITY_ID) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" - assert flow.get("handler") == DOMAIN - - assert "context" in flow - assert flow["context"].get("source") == SOURCE_REAUTH - assert flow["context"].get("entry_id") == entry.entry_id + assert hass.states.get(entity_id).state == state diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py index 8910487ea58..3083dd8c629 100644 --- a/tests/components/totalconnect/test_binary_sensor.py +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -1,91 +1,29 @@ """Tests for the TotalConnect binary sensor.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR, - BinarySensorDeviceClass, -) -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import RESPONSE_DISARMED, ZONE_NORMAL, setup_platform +from . import setup_integration -from tests.common import snapshot_platform - -ZONE_ENTITY_ID = "binary_sensor.security" -ZONE_LOW_BATTERY_ID = "binary_sensor.security_battery" -ZONE_TAMPER_ID = "binary_sensor.security_tamper" -PANEL_BATTERY_ID = "binary_sensor.test_battery" -PANEL_TAMPER_ID = "binary_sensor.test_tamper" -PANEL_POWER_ID = "binary_sensor.test_power" +from tests.common import MockConfigEntry, snapshot_platform async def test_entity_registry( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Test the binary sensor is registered in entity registry.""" - entry = await setup_platform(hass, BINARY_SENSOR) - - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) - - -async def test_state_and_attributes(hass: HomeAssistant) -> None: - """Test the binary sensor attributes are correct.""" - + """Test the alarm control panel attributes are correct.""" with patch( - "homeassistant.components.totalconnect.TotalConnectClient.request", - return_value=RESPONSE_DISARMED, + "homeassistant.components.totalconnect.PLATFORMS", [Platform.BINARY_SENSOR] ): - await setup_platform(hass, BINARY_SENSOR) + await setup_integration(hass, mock_config_entry) - state = hass.states.get(ZONE_ENTITY_ID) - assert state.state == STATE_ON - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == ZONE_NORMAL["ZoneDescription"] - ) - assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR - - state = hass.states.get(f"{ZONE_ENTITY_ID}_battery") - assert state.state == STATE_OFF - state = hass.states.get(f"{ZONE_ENTITY_ID}_tamper") - assert state.state == STATE_OFF - - # Zone 2 is fire with low battery - state = hass.states.get("binary_sensor.fire") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.SMOKE - state = hass.states.get("binary_sensor.fire_battery") - assert state.state == STATE_ON - state = hass.states.get("binary_sensor.fire_tamper") - assert state.state == STATE_OFF - - # Zone 3 is gas with tamper - state = hass.states.get("binary_sensor.gas") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.GAS - state = hass.states.get("binary_sensor.gas_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.gas_tamper") - assert state.state == STATE_ON - - # Zone 6 is unknown type, assume it is a security (door) sensor - state = hass.states.get("binary_sensor.unknown") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR - state = hass.states.get("binary_sensor.unknown_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.unknown_tamper") - assert state.state == STATE_OFF - - # Zone 7 is temperature - state = hass.states.get("binary_sensor.temperature") - assert state.state == STATE_OFF - assert state.attributes.get("device_class") == BinarySensorDeviceClass.PROBLEM - state = hass.states.get("binary_sensor.temperature_battery") - assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.temperature_tamper") - assert state.state == STATE_OFF + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 092b058e693..9492d815152 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -1,84 +1,64 @@ """Tests for the TotalConnect buttons.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -import pytest from syrupy.assertion import SnapshotAssertion -from total_connect_client.exceptions import FailedToBypassZone -from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from . import setup_integration -from tests.common import snapshot_platform - -ZONE_BYPASS_ID = "button.security_bypass" -PANEL_CLEAR_ID = "button.test_clear_bypass" -PANEL_BYPASS_ID = "button.test_bypass_all" +from tests.common import MockConfigEntry, snapshot_platform async def test_entity_registry( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the button is registered in entity registry.""" - entry = await setup_platform(hass, BUTTON) + with patch("homeassistant.components.totalconnect.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize( - ("entity_id", "tcc_request"), - [ - (ZONE_BYPASS_ID, "total_connect_client.zone.TotalConnectZone.bypass"), - ( - PANEL_BYPASS_ID, - "total_connect_client.location.TotalConnectLocation.zone_bypass_all", - ), - ], -) async def test_bypass_button( - hass: HomeAssistant, entity_id: str, tcc_request: str + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_location: AsyncMock, ) -> None: """Test pushing a bypass button.""" - responses = [FailedToBypassZone, None] - await setup_platform(hass, BUTTON) - with patch(tcc_request, side_effect=responses) as mock_request: - # try to bypass, but fails - with pytest.raises(FailedToBypassZone): - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_request.call_count == 1 - - # try to bypass, works this time - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data={ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert mock_request.call_count == 2 - - -async def test_clear_button(hass: HomeAssistant) -> None: - """Test pushing the clear bypass button.""" - data = {ATTR_ENTITY_ID: PANEL_CLEAR_ID} - await setup_platform(hass, BUTTON) - TOTALCONNECT_REQUEST = ( - "total_connect_client.location.TotalConnectLocation.clear_bypass" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.security_bypass"}, + blocking=True, ) - with patch(TOTALCONNECT_REQUEST) as mock_request: - await hass.services.async_call( - domain=BUTTON, - service=SERVICE_PRESS, - service_data=data, - blocking=True, - ) - assert mock_request.call_count == 1 + assert mock_location.zones[2].bypass.call_count == 1 + + +async def test_clear_button( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_location: AsyncMock, +) -> None: + """Test pushing the clear bypass button.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_clear_bypass"}, + blocking=True, + ) + + assert mock_location.clear_bypass.call_count == 1 diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index b7ac42c84b5..dbbff265129 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the TotalConnect config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from total_connect_client.exceptions import AuthenticationError @@ -11,217 +11,235 @@ from homeassistant.components.totalconnect.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .common import ( - CONFIG_DATA, - CONFIG_DATA_NO_USERCODES, - RESPONSE_DISARMED, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_SESSION_DETAILS, - RESPONSE_SUCCESS, - RESPONSE_USER_CODE_INVALID, - TOTALCONNECT_GET_CONFIG, - TOTALCONNECT_REQUEST, - TOTALCONNECT_REQUEST_TOKEN, - USERNAME, - init_integration, -) +from . import setup_integration +from .const import LOCATION_ID, PASSWORD, USERNAME from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: AsyncMock +) -> None: """Test user step.""" - # user starts with no data entered, so show the user form result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=None, + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) -async def test_user_show_locations(hass: HomeAssistant) -> None: - """Test user locations form.""" - # user/pass provided, so check if valid then ask for usercodes on locations form - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_USER_CODE_INVALID, - RESPONSE_SUCCESS, - ] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA_NO_USERCODES, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_PASSWORD: PASSWORD, + CONF_USERNAME: USERNAME, + CONF_USERCODES: {LOCATION_ID: "7890"}, + } + assert result["title"] == "Total Connect" + assert result["options"] == {} + assert result["result"].unique_id == USERNAME + + +async def test_login_errors( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_client: AsyncMock +) -> None: + """Test login errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + ) as client: + client.side_effect = AuthenticationError() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} ) - # first it should show the locations form - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "locations" - # client should have sent four requests for init - assert mock_request.call_count == 4 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} - # user enters an invalid usercode - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_USERCODES: "bad"}, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "locations" - # client should have sent 5th request to validate usercode - assert mock_request.call_count == 5 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) - # user enters a valid usercode - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={CONF_USERCODES: "7890"}, - ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - # client should have sent another request to validate usercode - assert mock_request.call_count == 6 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +async def test_usercode_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_location: AsyncMock, +) -> None: + """Test user step with usercode errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + + mock_location.set_usercode.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "locations" + assert result["errors"] == {CONF_LOCATION: "usercode"} + + mock_location.set_usercode.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERCODES: "7890"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_no_locations( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_location: AsyncMock, +) -> None: + """Test no locations found.""" + + mock_client.get_number_locations.return_value = 0 + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_locations" + + +async def test_abort_if_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test abort if the account is already setup.""" - MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, - ).add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - # Should fail, same USERNAME (flow) - with patch("homeassistant.components.totalconnect.config_flow.TotalConnectClient"): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_login_failed(hass: HomeAssistant) -> None: - """Test when we have errors during login.""" - with patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient" - ) as client_mock: - client_mock.side_effect = AuthenticationError() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA, - ) +async def test_reauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test login errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.totalconnect.config_flow.TotalConnectClient" - ) as client_mock, - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - ): - # first test with an invalid password - client_mock.side_effect = AuthenticationError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "abc"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "abc" + + +async def test_reauth_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test login errors.""" + 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" + + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient", + ) as client: + client.side_effect = AuthenticationError() result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PASSWORD: "password"} + result["flow_id"], {CONF_PASSWORD: PASSWORD} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] == {"base": "invalid_auth"} - # now test with the password valid - client_mock.side_effect = None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PASSWORD: "password"} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: PASSWORD} + ) - assert len(hass.config_entries.async_entries()) == 1 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" -async def test_no_locations(hass: HomeAssistant) -> None: - """Test with no user locations.""" - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - ] - - with ( - patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request, - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - patch( - "homeassistant.components.totalconnect.async_setup_entry", return_value=True - ), - patch( - "homeassistant.components.totalconnect.TotalConnectClient.get_number_locations", - return_value=0, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=CONFIG_DATA_NO_USERCODES, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_locations" - await hass.async_block_till_done() - - assert mock_request.call_count == 1 - - -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test config flow options.""" - config_entry = await init_integration(hass) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await setup_integration(hass, mock_config_entry) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -231,8 +249,4 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert mock_config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} diff --git a/tests/components/totalconnect/test_diagnostics.py b/tests/components/totalconnect/test_diagnostics.py index 2ad05c60936..7422ee36143 100644 --- a/tests/components/totalconnect/test_diagnostics.py +++ b/tests/components/totalconnect/test_diagnostics.py @@ -1,36 +1,29 @@ """Test TotalConnect diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant -from .common import LOCATION_ID, init_integration +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_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + mock_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass) + await setup_integration(hass, mock_config_entry) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - client = result["client"] - assert client["invalid_credentials"] is False - - user = result["user"] - assert user["master"] is False - - location = result["locations"][0] - assert location["location_id"] == LOCATION_ID - - device = location["devices"][0] - assert device["serial_number"] == REDACTED - - partition = location["partitions"][0] - assert partition["name"] == "Test1" - - zone = location["zones"][0] - assert zone["zone_id"] == "1" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/totalconnect/test_init.py b/tests/components/totalconnect/test_init.py index ba533e19798..b19f585965f 100644 --- a/tests/components/totalconnect/test_init.py +++ b/tests/components/totalconnect/test_init.py @@ -4,29 +4,23 @@ from unittest.mock import patch from total_connect_client.exceptions import AuthenticationError -from homeassistant.components.totalconnect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .common import CONFIG_DATA +from . import setup_integration from tests.common import MockConfigEntry -async def test_reauth_started(hass: HomeAssistant) -> None: +async def test_reauth_start( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test that reauth is started when we have login errors.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - ) - mock_entry.add_to_hass(hass) - with patch( "homeassistant.components.totalconnect.TotalConnectClient", ) as mock_client: mock_client.side_effect = AuthenticationError() - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) - assert mock_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR From b48409ab1b9b5787d318d60863ec1fd7916fdbd5 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sun, 10 Aug 2025 22:28:50 +0200 Subject: [PATCH 1083/1118] Add new sensors with battery data for solarlog (#150385) Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- homeassistant/components/solarlog/sensor.py | 56 +++++- .../components/solarlog/strings.json | 9 + .../solarlog/fixtures/solarlog_data.json | 7 +- .../solarlog/snapshots/test_diagnostics.ambr | 7 +- .../solarlog/snapshots/test_sensor.ambr | 165 ++++++++++++++++++ 5 files changed, 241 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index c4bb119c006..a3a450fe49e 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from solarlog_cli.solarlog_models import InverterData, SolarlogData +from solarlog_cli.solarlog_models import BatteryData, InverterData, SolarlogData from homeassistant.components.sensor import ( SensorDeviceClass, @@ -35,6 +35,13 @@ class SolarLogCoordinatorSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[SolarlogData], StateType | datetime | None] +@dataclass(frozen=True, kw_only=True) +class SolarLogBatterySensorEntityDescription(SensorEntityDescription): + """Describes Solarlog battery sensor entity.""" + + value_fn: Callable[[BatteryData], float | int | None] + + @dataclass(frozen=True, kw_only=True) class SolarLogInverterSensorEntityDescription(SensorEntityDescription): """Describes Solarlog inverter sensor entity.""" @@ -247,6 +254,33 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ), ) +BATTERY_SENSOR_TYPES: tuple[SolarLogBatterySensorEntityDescription, ...] = ( + SolarLogBatterySensorEntityDescription( + key="charging_power", + translation_key="charging_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.charge_power, + ), + SolarLogBatterySensorEntityDescription( + key="discharging_power", + translation_key="discharging_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.discharge_power, + ), + SolarLogBatterySensorEntityDescription( + key="charge_level", + translation_key="charge_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery_data: battery_data.level, + ), +) + INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( SolarLogInverterSensorEntityDescription( key="current_power", @@ -286,6 +320,13 @@ async def async_setup_entry( for sensor in SOLARLOG_SENSOR_TYPES ] + # add battery sensors only if respective data is available (otherwise no battery attached to solarlog) + if coordinator.data.battery_data is not None: + entities.extend( + SolarLogBatterySensor(coordinator, sensor) + for sensor in BATTERY_SENSOR_TYPES + ) + device_data = coordinator.data.inverter_data if device_data: @@ -318,6 +359,19 @@ class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): return self.entity_description.value_fn(self.coordinator.data) +class SolarLogBatterySensor(SolarLogCoordinatorEntity, SensorEntity): + """Represents a SolarLog battery sensor.""" + + entity_description: SolarLogBatterySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state for this sensor.""" + if (battery_data := self.coordinator.data.battery_data) is None: + return None + return self.entity_description.value_fn(battery_data) + + class SolarLogInverterSensor(SolarLogInverterEntity, SensorEntity): """Represents a SolarLog inverter sensor.""" diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index bf87b0b0938..bba1380fb9f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -58,6 +58,15 @@ }, "entity": { "sensor": { + "charge_level": { + "name": "Charge level" + }, + "charging_power": { + "name": "Charging power" + }, + "discharging_power": { + "name": "Discharging power" + }, "last_update": { "name": "Last update" }, diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json index 339ab4a4dfc..be29194a783 100644 --- a/tests/components/solarlog/fixtures/solarlog_data.json +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -21,5 +21,10 @@ "usage": 54.8, "power_available": 45.13, "capacity": 85.5, - "last_updated": "2024-08-01T15:20:45Z" + "last_updated": "2024-08-01T15:20:45Z", + "battery_data": { + "charge_power": 1074, + "discharge_power": 0, + "level": 79 + } } diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 5d91407dbbf..212742b82f0 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -26,7 +26,12 @@ }), 'solarlog_data': dict({ 'alternator_loss': 2.0, - 'battery_data': None, + 'battery_data': dict({ + 'charge_power': 1074.0, + 'discharge_power': 0.0, + 'level': 79.0, + 'voltage': 0, + }), 'capacity': 85.5, 'consumption_ac': 54.87, 'consumption_day': 5.31, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 8f0ee17df44..0ddccf6a193 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -341,6 +341,115 @@ 'state': '85.5', }) # --- +# name: test_all_entities[sensor.solarlog_charge_level-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.solarlog_charge_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge level', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_level', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_charge_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_charge_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SolarLog Charge level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_charge_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '79.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_charging_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.solarlog_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_charging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_charging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarLog Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_charging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1074.0', + }) +# --- # name: test_all_entities[sensor.solarlog_consumption_ac-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -689,6 +798,62 @@ 'state': '0.00734', }) # --- +# name: test_all_entities[sensor.solarlog_discharging_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.solarlog_discharging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharging power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharging_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_discharging_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_discharging_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarLog Discharging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_discharging_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[sensor.solarlog_efficiency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7572b2a6693c26e4f79bd917e55d5c399ce25034 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 10 Aug 2025 22:38:49 +0200 Subject: [PATCH 1084/1118] Bump pymodbus to v3.11.1. (#150383) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 656b69920a0..32a043c4379 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], - "requirements": ["pymodbus==3.11.0"] + "requirements": ["pymodbus==3.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 599f8031dfc..98da1dccb60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.11.0 +pymodbus==3.11.1 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 668dd2ad35a..5839dbe0d51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1791,7 +1791,7 @@ pymiele==0.5.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.11.0 +pymodbus==3.11.1 # homeassistant.components.monoprice pymonoprice==0.4 From 9561c849208bd5310840f27dd508804539e5fa05 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 10 Aug 2025 22:39:00 +0200 Subject: [PATCH 1085/1118] Fix issue with Tuya suggested unit (#150394) --- homeassistant/components/tuya/sensor.py | 2 + .../tuya/snapshots/test_sensor.ambr | 104 ++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index dce08024ca3..6b9aa5b37a8 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1575,6 +1575,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.unique_id, ) self._attr_device_class = None + self._attr_suggested_unit_of_measurement = None return uoms = DEVICE_CLASS_UNITS[self.device_class] @@ -1585,6 +1586,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Unknown unit of measurement, device class should not be used. if uom is None: self._attr_device_class = None + self._attr_suggested_unit_of_measurement = None return # Found unit of measurement, use the standardized Unit diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 5b74b6cbfbf..073d8af7d69 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4390,6 +4390,58 @@ 'state': '32.2', }) # --- +# name: test_platform_setup_and_discovery[sensor.hl400_pm2_5-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.hl400_pm2_5', + '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': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.zfHZQ7tZUBxAWjACjkpm25', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.hl400_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HL400 PM2.5', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.hl400_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.hot_water_heat_pump_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5430,6 +5482,58 @@ 'state': '232.1', }) # --- +# name: test_platform_setup_and_discovery[sensor.ion1000pro_pm2_5-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.ion1000pro_pm2_5', + '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': 'PM2.5', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': 'tuya.owozxdzgbibizu4sjkpm25', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.ion1000pro_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ION1000PRO PM2.5', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.ion1000pro_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.kalado_air_purifier_air_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 84de6aacfc1657b828e7ae5aa04bfa70e0153ec8 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 10 Aug 2025 23:41:37 +0300 Subject: [PATCH 1086/1118] Remove native field from conversation chatlog delta listeners (#150389) --- homeassistant/components/conversation/chat_log.py | 7 ++++++- tests/components/conversation/test_chat_log.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index b5348e50b5c..7d842b3c562 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -342,7 +342,12 @@ class ChatLog: name=f"llm_tool_{tool_call.id}", ) if self.delta_listener: - self.delta_listener(self, delta) # type: ignore[arg-type] + if filtered_delta := { + k: v for k, v in delta.items() if k != "native" + }: + # We do not want to send the native content to the listener + # as it is not JSON serializable + self.delta_listener(self, filtered_delta) continue # Starting a new message diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 8fefb41475a..a5ed3146ddc 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -568,7 +568,8 @@ async def test_add_delta_content_stream( """Yield deltas.""" for d in deltas: yield d - expected_delta.append(d) + if filtered_delta := {k: v for k, v in d.items() if k != "native"}: + expected_delta.append(filtered_delta) captured_deltas = [] From 7b5dd4a0ec2c07efb76a624e632d32047c94b514 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sun, 10 Aug 2025 23:36:36 +0200 Subject: [PATCH 1087/1118] Paperless-ngx: Disable entities by default and extended docs (#149473) --- .../components/paperless_ngx/quality_scale.yaml | 16 ++++++++-------- homeassistant/components/paperless_ngx/sensor.py | 7 +++++++ tests/components/paperless_ngx/test_sensor.py | 1 + 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/paperless_ngx/quality_scale.yaml b/homeassistant/components/paperless_ngx/quality_scale.yaml index 827d4425132..f0d3296da10 100644 --- a/homeassistant/components/paperless_ngx/quality_scale.yaml +++ b/homeassistant/components/paperless_ngx/quality_scale.yaml @@ -50,19 +50,19 @@ rules: discovery: status: exempt comment: Paperless does not support discovery. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: Service type integration entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index 5d6bfe1347e..fd066f23240 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -56,24 +56,28 @@ SENSOR_STATISTICS: tuple[PaperlessEntityDescription[Statistic], ...] = ( translation_key="characters_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.character_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="tag_count", translation_key="tag_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.tag_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="correspondent_count", translation_key="correspondent_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.correspondent_count, + entity_registry_enabled_default=False, ), PaperlessEntityDescription[Statistic]( key="document_type_count", translation_key="document_type_count", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.document_type_count, + entity_registry_enabled_default=False, ), ) @@ -141,6 +145,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="index_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], @@ -159,6 +164,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="classifier_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], @@ -177,6 +183,7 @@ SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( translation_key="celery_status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, options=[ item.value.lower() for item in StatusType if item != StatusType.UNKNOWN ], diff --git a/tests/components/paperless_ngx/test_sensor.py b/tests/components/paperless_ngx/test_sensor.py index d2233a64ee2..5b5827bca37 100644 --- a/tests/components/paperless_ngx/test_sensor.py +++ b/tests/components/paperless_ngx/test_sensor.py @@ -29,6 +29,7 @@ from tests.common import ( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_platform( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From c7f5e25d41d737b6f32e7240680d37cacdbe2925 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:36:57 +0200 Subject: [PATCH 1088/1118] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20Uptime=20Kuma=20(#148951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/uptime_kuma/manifest.json | 2 +- homeassistant/components/uptime_kuma/quality_scale.yaml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 42fac89a976..6ea7150f15d 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", "iot_class": "cloud_polling", "loggers": ["pythonkuma"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["pythonkuma==0.3.1"] } diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 3c9b5a3af50..56274d868ae 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -49,14 +49,14 @@ rules: status: done comment: hassio addon supports discovery, other installation methods are not discoverable docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: status: exempt comment: integration is a service docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done From 167e9c8f4a025088746b9c1a9c47b731ad777f1e Mon Sep 17 00:00:00 2001 From: Manuel Stahl Date: Mon, 11 Aug 2025 09:43:09 +0200 Subject: [PATCH 1089/1118] Update pystiebeleltron to 0.2.3 (#150339) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/stiebel_eltron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index f8140ed36d7..7418c5b7b32 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "requirements": ["pystiebeleltron==0.1.0"] + "requirements": ["pystiebeleltron==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 98da1dccb60..3aa3f2ce28f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2382,7 +2382,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.1 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.1.0 +pystiebeleltron==0.2.3 # homeassistant.components.suez_water pysuezV2==2.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5839dbe0d51..026c6364b46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1985,7 +1985,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.1 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.1.0 +pystiebeleltron==0.2.3 # homeassistant.components.suez_water pysuezV2==2.0.7 From 0089d3efa1bb7a7b8dcd39ac2fbadd8ba73ccb2d Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:24:20 -0700 Subject: [PATCH 1090/1118] Support `multiple` for StateSelector (#146288) --- homeassistant/helpers/selector.py | 12 +++++++++--- tests/helpers/test_selector.py | 7 ++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ad0c909003e..1003991ccec 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1333,6 +1333,7 @@ class StateSelectorConfig(BaseSelectorConfig, total=False): entity_id: str hide_states: list[str] + multiple: bool @SELECTORS.register("state") @@ -1350,6 +1351,7 @@ class StateSelector(Selector[StateSelectorConfig]): # selectors into two types: one for state and one for attribute. # Limiting the public use, prevents breaking changes in the future. # vol.Optional("attribute"): str, + vol.Optional("multiple", default=False): cv.boolean, } ) @@ -1357,10 +1359,14 @@ class StateSelector(Selector[StateSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - state: str = vol.Schema(str)(data) - return state + if not self.config["multiple"]: + state: str = vol.Schema(str)(data) + return state + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] class StatisticSelectorConfig(BaseSelectorConfig, total=False): diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 50d9da501c5..7f5255a203b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -563,7 +563,12 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ( {"entity_id": "sensor.abc"}, ("on", "armed"), - (None, True, 1), + (None, True, 1, ["on"]), + ), + ( + {"entity_id": "sensor.abc", "multiple": True}, + (["on"], ["on", "off"], []), + (None, True, 1, [True], [1], "on"), ), ( {"hide_states": ["unknown", "unavailable"]}, From 330dce24c5c1711145300693cc766ffe42d2cce8 Mon Sep 17 00:00:00 2001 From: Tomeroeni <30298350+Tomeroeni@users.noreply.github.com> Date: Mon, 11 Aug 2025 10:32:35 +0200 Subject: [PATCH 1091/1118] Bump aiounifi to version 86 (#150321) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index d13b180d62d..c766af47951 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==84"], + "requirements": ["aiounifi==86"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 3aa3f2ce28f..b99ce3c9585 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==84 +aiounifi==86 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 026c6364b46..00129d8e44f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==84 +aiounifi==86 # homeassistant.components.usb aiousbwatcher==1.1.1 From d8b576c087335c5e4b8463ccf1709fbda605aea4 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 11 Aug 2025 10:37:25 +0200 Subject: [PATCH 1092/1118] Rename local OAuth2 source (#150403) --- homeassistant/helpers/config_entry_oauth2_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 1671e8e2cc2..0f8bdfd7793 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -155,7 +155,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def name(self) -> str: """Name of the implementation.""" - return "Configuration.yaml" + return "Local application credentials" @property def domain(self) -> str: From 00c78385876348b80850b0a45f9d8f056fbf0141 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 11 Aug 2025 10:58:03 +0200 Subject: [PATCH 1093/1118] Update frontend to 20250811.0 (#150404) --- 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 61ca88ba70a..3488ddc5e5c 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==20250806.0"] + "requirements": ["home-assistant-frontend==20250811.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ef3843e7da4..165bd547dae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==5.0.1 hass-nabucasa==0.111.2 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250806.0 +home-assistant-frontend==20250811.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b99ce3c9585..d3588839c48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250806.0 +home-assistant-frontend==20250811.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00129d8e44f..2d93ec1ff94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250806.0 +home-assistant-frontend==20250811.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From 84ce5d65e154dde3da788c01779b0cb5d1cbe038 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:50:00 +0200 Subject: [PATCH 1094/1118] Bump github/codeql-action from 3.29.7 to 3.29.8 (#150405) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f9795c219c5..66bd9c7ce2c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.7 + uses: github/codeql-action/init@v3.29.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.7 + uses: github/codeql-action/analyze@v3.29.8 with: category: "/language:python" From 2a5a66f9d5cc52aa634fc1989f0d50723db60b34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:55:47 +0200 Subject: [PATCH 1095/1118] Handle empty electricity RAW sensors in Tuya (#150406) --- homeassistant/components/tuya/models.py | 6 ++++-- homeassistant/components/tuya/sensor.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 43e4c04c518..059889b754f 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -108,7 +108,7 @@ class ComplexTypeData: raise NotImplementedError("from_json is not implemented for this type") @classmethod - def from_raw(cls, data: str) -> Self: + def from_raw(cls, data: str) -> Self | None: """Decode base64 string and return a ComplexTypeData object.""" raise NotImplementedError("from_raw is not implemented for this type") @@ -127,9 +127,11 @@ class ElectricityTypeData(ComplexTypeData): return cls(**json.loads(data.lower())) @classmethod - def from_raw(cls, data: str) -> Self: + def from_raw(cls, data: str) -> Self | None: """Decode base64 string and return a ElectricityTypeData object.""" raw = base64.b64decode(data) + if len(raw) == 0: + return None voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 6b9aa5b37a8..a22eba6cc35 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1636,10 +1636,11 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): if ( self.entity_description.complex_type is None or self.entity_description.subkey is None + or (raw_values := self.entity_description.complex_type.from_raw(value)) + is None ): return None - values = self.entity_description.complex_type.from_raw(value) - return getattr(values, self.entity_description.subkey) + return getattr(raw_values, self.entity_description.subkey) # Valid string or enum value return value From 23e6148d3b2fca0947b24f5213d9b50a6f8e62f6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 11 Aug 2025 02:58:12 -0700 Subject: [PATCH 1096/1118] Create an issue if Opower utility is no longer supported (#150315) --- homeassistant/components/opower/__init__.py | 23 ++++++ homeassistant/components/opower/repairs.py | 44 +++++++++++ homeassistant/components/opower/strings.json | 11 +++ tests/components/opower/test_repairs.py | 82 ++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 homeassistant/components/opower/repairs.py create mode 100644 tests/components/opower/test_repairs.py diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 23c8e7a8136..088083ef5db 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -2,9 +2,13 @@ from __future__ import annotations +from opower import select_utility + from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from .const import CONF_UTILITY, DOMAIN from .coordinator import OpowerConfigEntry, OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -12,6 +16,25 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" + utility_name = entry.data[CONF_UTILITY] + try: + select_utility(utility_name) + except ValueError: + ir.async_create_issue( + hass, + DOMAIN, + f"unsupported_utility_{entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_utility", + translation_placeholders={"utility": utility_name}, + data={ + "entry_id": entry.entry_id, + "utility": utility_name, + "title": entry.title, + }, + ) + return False coordinator = OpowerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/opower/repairs.py b/homeassistant/components/opower/repairs.py new file mode 100644 index 00000000000..f78dee32194 --- /dev/null +++ b/homeassistant/components/opower/repairs.py @@ -0,0 +1,44 @@ +"""Repairs for Opower.""" + +from __future__ import annotations + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + + +class UnsupportedUtilityFixFlow(RepairsFlow): + """Handler for removing a configuration entry that uses an unsupported utility.""" + + def __init__(self, data: dict[str, str]) -> None: + """Initialize.""" + self._entry_id = data["entry_id"] + self._placeholders = data.copy() + self._placeholders.pop("entry_id") + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + await self.hass.config_entries.async_remove(self._entry_id) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", description_placeholders=self._placeholders + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None +) -> RepairsFlow: + """Create flow.""" + assert issue_id.startswith("unsupported_utility") + assert data + return UnsupportedUtilityFixFlow(data) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index c2cd4227da0..813e1185467 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -70,6 +70,17 @@ "return_to_grid_migration": { "title": "Return to grid statistics for account: {utility_account_id}", "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." + }, + "unsupported_utility": { + "title": "Unsupported utility: {utility}", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::opower::issues::unsupported_utility::title%]", + "description": "The utility `{utility}` used by entry `{title}` is no longer supported by the Opower integration. Select **Submit** to remove this integration entry now." + } + } + } } }, "entity": { diff --git a/tests/components/opower/test_repairs.py b/tests/components/opower/test_repairs.py new file mode 100644 index 00000000000..7f589be6a26 --- /dev/null +++ b/tests/components/opower/test_repairs.py @@ -0,0 +1,82 @@ +"""Test the Opower repairs.""" + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_unsupported_utility_fix_flow( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the unsupported utility fix flow.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "utility": "Unsupported Utility", + "username": "test-user", + "password": "test-password", + }, + title="My Unsupported Utility", + ) + mock_config_entry.add_to_hass(hass) + + # Setting up the component with an unsupported utility should fail and create an issue + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + # Verify the issue was created correctly + issue_id = f"unsupported_utility_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "unsupported_utility" + assert issue.is_fixable is True + assert issue.data == { + "entry_id": mock_config_entry.entry_id, + "utility": "Unsupported Utility", + "title": "My Unsupported Utility", + } + + await async_process_repairs_platforms(hass) + http_client = await hass_client() + + # Start the repair flow + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) + flow_id = data["flow_id"] + + # The flow should go directly to the confirm step + assert data["step_id"] == "confirm" + assert data["description_placeholders"] == { + "utility": "Unsupported Utility", + "title": "My Unsupported Utility", + } + + # Submit the confirmation form + data = await process_repair_fix_flow(http_client, flow_id, json={}) + + # The flow should complete and create an empty entry, signaling success + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + # Check that the config entry has been removed + assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) is None + # Check that the issue has been resolved + assert not issue_registry.async_get_issue(DOMAIN, issue_id) From 203c9087301c2e7068e038a6eef0b0a9c3360171 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 11 Aug 2025 19:59:39 +1000 Subject: [PATCH 1097/1118] Add charging and preconditioning actions to Teslemetry (#144184) --- .../components/teslemetry/icons.json | 12 ++ .../components/teslemetry/services.py | 203 ++++++++++++++++++ .../components/teslemetry/services.yaml | 136 ++++++++++++ .../components/teslemetry/strings.json | 154 ++++++++++++- tests/components/teslemetry/test_services.py | 160 ++++++++++---- 5 files changed, 616 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index edd5d404499..f50f5a75f70 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -773,6 +773,18 @@ }, "time_of_use": { "service": "mdi:clock-time-eight-outline" + }, + "add_charge_schedule": { + "service": "mdi:calendar-plus" + }, + "remove_charge_schedule": { + "service": "mdi:calendar-minus" + }, + "add_precondition_schedule": { + "service": "mdi:hvac-outline" + }, + "remove_precondition_schedule": { + "service": "mdi:hvac-off-outline" } } } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 246cc097a2a..7a6a7b55c0c 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -22,6 +22,7 @@ ATTR_ID = "id" ATTR_GPS = "gps" ATTR_TYPE = "type" ATTR_VALUE = "value" +ATTR_LOCATION = "location" ATTR_LOCALE = "locale" ATTR_ORDER = "order" ATTR_TIMESTAMP = "timestamp" @@ -36,6 +37,12 @@ ATTR_DEPARTURE_TIME = "departure_time" ATTR_OFF_PEAK_CHARGING_ENABLED = "off_peak_charging_enabled" ATTR_OFF_PEAK_CHARGING_WEEKDAYS = "off_peak_charging_weekdays_only" ATTR_END_OFF_PEAK_TIME = "end_off_peak_time" +ATTR_DAYS_OF_WEEK = "days_of_week" +ATTR_START_TIME = "start_time" +ATTR_END_TIME = "end_time" +ATTR_ONE_TIME = "one_time" +ATTR_NAME = "name" +ATTR_PRECONDITION_TIME = "precondition_time" # Services SERVICE_NAVIGATE_ATTR_GPS_REQUEST = "navigation_gps_request" @@ -44,6 +51,10 @@ SERVICE_SET_SCHEDULED_DEPARTURE = "set_scheduled_departure" SERVICE_VALET_MODE = "valet_mode" SERVICE_SPEED_LIMIT = "speed_limit" SERVICE_TIME_OF_USE = "time_of_use" +SERVICE_ADD_CHARGE_SCHEDULE = "add_charge_schedule" +SERVICE_REMOVE_CHARGE_SCHEDULE = "remove_charge_schedule" +SERVICE_ADD_PRECONDITION_SCHEDULE = "add_precondition_schedule" +SERVICE_REMOVE_PRECONDITION_SCHEDULE = "remove_precondition_schedule" def async_get_device_for_service_call( @@ -315,3 +326,195 @@ def async_setup_services(hass: HomeAssistant) -> None: } ), ) + + async def add_charge_schedule(call: ServiceCall) -> None: + """Configure charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) + enabled = call.data[ATTR_ENABLE] + + # Optional parameters + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + + # Handle time inputs + start_time = None + if start_time_obj := call.data.get(ATTR_START_TIME): + # Convert time object to minutes since midnight + start_time = start_time_obj.hour * 60 + start_time_obj.minute + + end_time = None + if end_time_obj := call.data.get(ATTR_END_TIME): + # Convert time object to minutes since midnight + end_time = end_time_obj.hour * 60 + end_time_obj.minute + + one_time = call.data.get(ATTR_ONE_TIME) + schedule_id = call.data.get(ATTR_ID) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_charge_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + start_time=start_time, + end_time=end_time, + one_time=one_time, + id=schedule_id, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + add_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_LOCATION): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Optional(ATTR_START_TIME): cv.time, + vol.Optional(ATTR_END_TIME): cv.time, + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_charge_schedule(call: ServiceCall) -> None: + """Remove a charging schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_charge_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + remove_charge_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) + + async def add_precondition_schedule(call: ServiceCall) -> None: + """Add or modify a precondition schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + days_of_week = call.data[ATTR_DAYS_OF_WEEK] + # If days_of_week is a list (from select with multiple), convert to comma-separated string + if isinstance(days_of_week, list): + days_of_week = ",".join(days_of_week) + enabled = call.data[ATTR_ENABLE] + location = call.data.get( + ATTR_LOCATION, + { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ) + + # Convert time object to minutes since midnight + precondition_time = ( + call.data[ATTR_PRECONDITION_TIME].hour * 60 + + call.data[ATTR_PRECONDITION_TIME].minute + ) + + # Optional parameters + schedule_id = call.data.get(ATTR_ID) + one_time = call.data.get(ATTR_ONE_TIME) + name = call.data.get(ATTR_NAME) + + await handle_vehicle_command( + vehicle.api.add_precondition_schedule( + days_of_week=days_of_week, + enabled=enabled, + lat=location[CONF_LATITUDE], + lon=location[CONF_LONGITUDE], + precondition_time=precondition_time, + id=schedule_id, + one_time=one_time, + name=name, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + add_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, + vol.Required(ATTR_ENABLE): cv.boolean, + vol.Optional(ATTR_LOCATION): { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + }, + vol.Required(ATTR_PRECONDITION_TIME): cv.time, + vol.Optional(ATTR_ID): cv.positive_int, + vol.Optional(ATTR_ONE_TIME): cv.boolean, + vol.Optional(ATTR_NAME): cv.string, + } + ), + ) + + async def remove_precondition_schedule(call: ServiceCall) -> None: + """Remove a preconditioning schedule for a vehicle.""" + device = async_get_device_for_service_call(hass, call) + config = async_get_config_for_device(hass, device) + vehicle = async_get_vehicle_for_entry(hass, device, config) + + # Extract parameters from the service call + schedule_id = call.data[ATTR_ID] + + await handle_vehicle_command( + vehicle.api.remove_precondition_schedule( + id=schedule_id, + ) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + remove_precondition_schedule, + schema=vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(ATTR_ID): cv.positive_int, + } + ), + ) diff --git a/homeassistant/components/teslemetry/services.yaml b/homeassistant/components/teslemetry/services.yaml index e98f124dd19..4c941c5d41d 100644 --- a/homeassistant/components/teslemetry/services.yaml +++ b/homeassistant/components/teslemetry/services.yaml @@ -130,3 +130,139 @@ speed_limit: min: 1000 max: 9999 mode: box + +add_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + select: + options: + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + - sunday + multiple: true + translation_key: days_of_week + enable: + required: true + selector: + boolean: + location: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + start_time: + required: false + selector: + time: + end_time: + required: false + selector: + time: + one_time: + required: false + selector: + boolean: + id: + required: false + selector: + number: + min: 1 + mode: box + name: + required: false + selector: + text: + +remove_charge_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box + +add_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + days_of_week: + required: true + selector: + select: + options: + - monday + - tuesday + - wednesday + - thursday + - friday + - saturday + - sunday + multiple: true + translation_key: days_of_week + enable: + required: true + selector: + boolean: + location: + required: false + example: '{"latitude": -27.9699373, "longitude": 153.4081865}' + selector: + location: + radius: false + precondition_time: + required: true + selector: + time: + id: + required: false + selector: + number: + min: 1 + mode: box + one_time: + required: false + selector: + boolean: + name: + required: false + selector: + text: + +remove_precondition_schedule: + fields: + device_id: + required: true + selector: + device: + filter: + integration: teslemetry + id: + required: true + selector: + number: + min: 1 + mode: box diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 646a3898cc7..510e2b45a02 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -3,6 +3,26 @@ "unavailable": "Unavailable", "abort": "Abort", "vehicle": "Vehicle", + "wake_up_failed": "Failed to wake up vehicle: {message}", + "wake_up_timeout": "Timed out trying to wake up vehicle", + "schedule_id": "Schedule ID", + "schedule_id_description": "The ID of the schedule, use an existing ID to modify.", + "days_of_week": "Days of week", + "days_of_week_description": "Select which days this schedule should be enabled on. You can select multiple days.", + "one_time": "One-time", + "one_time_description": "If this is a one-time schedule.", + "location_description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.", + "start_time": "Start time", + "start_time_description": "The time this schedule begins, e.g. 01:05 for 1:05 AM.", + "end_time": "End time", + "end_time_description": "The time this schedule ends, e.g. 01:05 for 1:05 AM.", + "precondition_time": "Precondition time", + "precondition_time_description": "The time the vehicle should complete preconditioning, e.g. 01:05 for 1:05 AM.", + "schedule_name_description": "The name of the schedule.", + "vehicle_to_schedule": "Vehicle to schedule.", + "vehicle_to_remove_schedule": "Vehicle to remove schedule from.", + "schedule_enable_description": "If this schedule should be considered for execution.", + "schedule_id_remove_description": "The ID of the schedule to remove.", "descr_pin": "4-digit code to enable or disable the setting" }, "config": { @@ -1079,15 +1099,6 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature" }, - "set_scheduled_charging_time": { - "message": "Time required to complete the operation" - }, - "set_scheduled_departure_preconditioning": { - "message": "Departure time required to enable preconditioning" - }, - "set_scheduled_departure_off_peak": { - "message": "To enable scheduled departure, 'End off-peak time' is required." - }, "invalid_device": { "message": "Invalid device ID: {device_id}" }, @@ -1145,7 +1156,7 @@ "description": "Sets a time at which charging should be started.", "fields": { "device_id": { - "description": "Vehicle to schedule.", + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { @@ -1167,7 +1178,7 @@ "name": "Departure time" }, "device_id": { - "description": "Vehicle to schedule.", + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", "name": "[%key:component::teslemetry::common::vehicle%]" }, "enable": { @@ -1246,6 +1257,127 @@ } }, "name": "Set valet mode" + }, + "add_charge_schedule": { + "description": "Adds or modifies a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "days_of_week": { + "description": "[%key:component::teslemetry::common::days_of_week_description%]", + "name": "[%key:component::teslemetry::common::days_of_week%]" + }, + "enable": { + "description": "[%key:component::teslemetry::common::schedule_enable_description%]", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "[%key:component::teslemetry::common::location_description%]", + "name": "Location" + }, + "start_time": { + "description": "[%key:component::teslemetry::common::start_time_description%]", + "name": "[%key:component::teslemetry::common::start_time%]" + }, + "end_time": { + "description": "[%key:component::teslemetry::common::end_time_description%]", + "name": "[%key:component::teslemetry::common::end_time%]" + }, + "one_time": { + "description": "[%key:component::teslemetry::common::one_time_description%]", + "name": "[%key:component::teslemetry::common::one_time%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + }, + "name": { + "description": "[%key:component::teslemetry::common::schedule_name_description%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "name": "Add charge schedule" + }, + "remove_charge_schedule": { + "description": "Removes a charging schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_remove_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_remove_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + } + }, + "name": "Remove charge schedule" + }, + "add_precondition_schedule": { + "description": "Adds or modifies a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "days_of_week": { + "description": "[%key:component::teslemetry::common::days_of_week_description%]", + "name": "[%key:component::teslemetry::common::days_of_week%]" + }, + "enable": { + "description": "[%key:component::teslemetry::common::schedule_enable_description%]", + "name": "[%key:common::action::enable%]" + }, + "location": { + "description": "[%key:component::teslemetry::common::location_description%]", + "name": "Location" + }, + "precondition_time": { + "description": "[%key:component::teslemetry::common::precondition_time_description%]", + "name": "[%key:component::teslemetry::common::precondition_time%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + }, + "one_time": { + "description": "[%key:component::teslemetry::common::one_time_description%]", + "name": "[%key:component::teslemetry::common::one_time%]" + }, + "name": { + "description": "[%key:component::teslemetry::common::schedule_name_description%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "name": "Add precondition schedule" + }, + "remove_precondition_schedule": { + "description": "Removes a preconditioning schedule for a vehicle.", + "fields": { + "device_id": { + "description": "[%key:component::teslemetry::common::vehicle_to_remove_schedule%]", + "name": "[%key:component::teslemetry::common::vehicle%]" + }, + "id": { + "description": "[%key:component::teslemetry::common::schedule_id_remove_description%]", + "name": "[%key:component::teslemetry::common::schedule_id%]" + } + }, + "name": "Remove precondition schedule" + } + }, + "selector": { + "days_of_week": { + "options": { + "monday": "[%key:common::time::monday%]", + "tuesday": "[%key:common::time::tuesday%]", + "wednesday": "[%key:common::time::wednesday%]", + "thursday": "[%key:common::time::thursday%]", + "friday": "[%key:common::time::friday%]", + "saturday": "[%key:common::time::saturday%]", + "sunday": "[%key:common::time::sunday%]" + } } } } diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index bcf5407999f..fecb8db0092 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -1,23 +1,36 @@ """Test the Teslemetry services.""" +from datetime import time from unittest.mock import patch import pytest from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.services import ( + ATTR_DAYS_OF_WEEK, ATTR_DEPARTURE_TIME, ATTR_ENABLE, ATTR_END_OFF_PEAK_TIME, + ATTR_END_TIME, ATTR_GPS, + ATTR_ID, + ATTR_LOCATION, + ATTR_NAME, ATTR_OFF_PEAK_CHARGING_ENABLED, ATTR_OFF_PEAK_CHARGING_WEEKDAYS, + ATTR_ONE_TIME, ATTR_PIN, + ATTR_PRECONDITION_TIME, ATTR_PRECONDITIONING_ENABLED, ATTR_PRECONDITIONING_WEEKDAYS, + ATTR_START_TIME, ATTR_TIME, ATTR_TOU_SETTINGS, + SERVICE_ADD_CHARGE_SCHEDULE, + SERVICE_ADD_PRECONDITION_SCHEDULE, SERVICE_NAVIGATE_ATTR_GPS_REQUEST, + SERVICE_REMOVE_CHARGE_SCHEDULE, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, SERVICE_SET_SCHEDULED_CHARGING, SERVICE_SET_SCHEDULED_DEPARTURE, SERVICE_SPEED_LIMIT, @@ -75,23 +88,12 @@ async def test_services( { CONF_DEVICE_ID: vehicle_device, ATTR_ENABLE: True, - ATTR_TIME: "6:00", + ATTR_TIME: "06:00", # 6:00 AM }, blocking=True, ) set_scheduled_charging.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_CHARGING, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, @@ -104,39 +106,15 @@ async def test_services( ATTR_ENABLE: True, ATTR_PRECONDITIONING_ENABLED: True, ATTR_PRECONDITIONING_WEEKDAYS: False, - ATTR_DEPARTURE_TIME: "6:00", + ATTR_DEPARTURE_TIME: "06:00", # 6:00 AM ATTR_OFF_PEAK_CHARGING_ENABLED: True, ATTR_OFF_PEAK_CHARGING_WEEKDAYS: False, - ATTR_END_OFF_PEAK_TIME: "5:00", + ATTR_END_OFF_PEAK_TIME: "05:00", # 5:00 AM }, blocking=True, ) set_scheduled_departure.assert_called_once() - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_PRECONDITIONING_ENABLED: True, - }, - blocking=True, - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_SCHEDULED_DEPARTURE, - { - CONF_DEVICE_ID: vehicle_device, - ATTR_ENABLE: True, - ATTR_OFF_PEAK_CHARGING_ENABLED: True, - }, - blocking=True, - ) - with patch( "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK, @@ -200,6 +178,112 @@ async def test_services( ) set_time_of_use.assert_called_once() + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_START_TIME: time(7, 0, 0), # 7:00 AM + ATTR_END_TIME: time(18, 0, 0), # 6:00 PM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Schedule", + }, + blocking=True, + ) + add_charge_schedule.assert_called_once() + + # Test add_charge_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_charge_schedule", + return_value=COMMAND_OK, + ) as add_charge_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + }, + blocking=True, + ) + add_charge_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_charge_schedule", + return_value=COMMAND_OK, + ) as remove_charge_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_CHARGE_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_charge_schedule.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_LOCATION: {CONF_LATITUDE: lat, CONF_LONGITUDE: lon}, + ATTR_PRECONDITION_TIME: time(7, 0, 0), # 7:00 AM + ATTR_ONE_TIME: False, + ATTR_NAME: "Test Precondition Schedule", + }, + blocking=True, + ) + add_precondition_schedule.assert_called_once() + + # Test add_precondition_schedule with minimal required parameters + with patch( + "tesla_fleet_api.teslemetry.Vehicle.add_precondition_schedule", + return_value=COMMAND_OK, + ) as add_precondition_schedule_minimal: + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_DAYS_OF_WEEK: ["Monday", "Tuesday"], + ATTR_ENABLE: True, + ATTR_PRECONDITION_TIME: time(8, 0, 0), # 8:00 AM + }, + blocking=True, + ) + add_precondition_schedule_minimal.assert_called_once() + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remove_precondition_schedule", + return_value=COMMAND_OK, + ) as remove_precondition_schedule: + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_PRECONDITION_SCHEDULE, + { + CONF_DEVICE_ID: vehicle_device, + ATTR_ID: 123, + }, + blocking=True, + ) + remove_precondition_schedule.assert_called_once() + with ( patch( "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", From 34b0b7137567e6ab33aa679da587ff5435a64d31 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:05:33 +0200 Subject: [PATCH 1098/1118] Add Tuya snapshot tests for empty electricity RAW sensors (#150407) --- tests/components/tuya/__init__.py | 1 + .../tuya/fixtures/dlq_z3jngbyubvwgfrcv.json | 222 +++++++ .../tuya/snapshots/test_sensor.ambr | 560 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++ 4 files changed, 831 insertions(+) create mode 100644 tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 0abac1062e3..700ad9116ed 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -99,6 +99,7 @@ DEVICE_MOCKS = [ "dlq_jdj6ccklup7btq3a", # https://github.com/home-assistant/core/issues/143209 "dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499 "dlq_r9kg2g1uhhyicycb", # https://github.com/home-assistant/core/issues/149650 + "dlq_z3jngbyubvwgfrcv", # https://github.com/home-assistant/core/issues/150293 "dr_pjvxl1wsyqxivsaf", # https://github.com/home-assistant/core/issues/84869 "fs_g0ewlb1vmwqljzji", # https://github.com/home-assistant/core/issues/141231 "fs_ibytpo6fpnugft1c", # https://github.com/home-assistant/core/issues/135541 diff --git a/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json b/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json new file mode 100644 index 00000000000..695b8a35414 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_z3jngbyubvwgfrcv.json @@ -0,0 +1,222 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Edesanya Energy", + "category": "dlq", + "product_id": "z3jngbyubvwgfrcv", + "product_name": "Breaker", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2025-06-16T11:30:16+00:00", + "create_time": "2025-06-16T11:30:16+00:00", + "update_time": "2025-06-16T11:30:16+00:00", + "function": { + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "random_time": { + "type": "String", + "value": {} + } + }, + "status_range": { + "total_forward_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm", + "phase_seq_err_alarm", + "vol_unbalance_alarm", + "low_current_alarm" + ] + } + }, + "switch_prepayment": { + "type": "Boolean", + "value": {} + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "charge_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 999999, + "scale": 2, + "step": 1 + } + }, + "leakage_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -20, + "max": 200, + "scale": 0, + "step": 1 + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "random_time": { + "type": "String", + "value": {} + } + }, + "status": { + "total_forward_energy": 21972, + "phase_a": "CT0AAmAAAIU=", + "phase_b": "", + "phase_c": "", + "fault": 0, + "switch_prepayment": false, + "energy_reset": "", + "balance_energy": 0, + "charge_energy": 0, + "leakage_current": 0, + "switch": true, + "alarm_set_1": "BAEAMgUBAFA=", + "alarm_set_2": "AQECdgMBARMEAQCv", + "temp_current": 24, + "countdown_1": 0, + "cycle_time": "EwAAAAAAAAAAAA==", + "random_time": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 073d8af7d69..00c170ca93c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -2992,6 +2992,566 @@ 'state': '241.9', }) # --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_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.edesanya_energy_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.608', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_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.edesanya_energy_phase_a_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.133', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_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.edesanya_energy_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.5', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_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.edesanya_energy_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_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.edesanya_energy_phase_b_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_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.edesanya_energy_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_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.edesanya_energy_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Edesanya Energy Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_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.edesanya_energy_phase_c_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Edesanya Energy Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_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.edesanya_energy_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldphase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Edesanya Energy Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_total_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.edesanya_energy_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldtotal_forward_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.edesanya_energy_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Edesanya Energy Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.edesanya_energy_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '219.72', + }) +# --- # name: test_platform_setup_and_discovery[sensor.elivco_kitchen_socket_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 493d4827e0a..0ab53480c22 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -2661,6 +2661,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[switch.edesanya_energy_switch-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.edesanya_energy_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.vcrfgwvbuybgnj3zqldswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.edesanya_energy_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Edesanya Energy Switch', + }), + 'context': , + 'entity_id': 'switch.edesanya_energy_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 73cbc962f998790ed9ea0931a33b1349e3c664cf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:11:24 +0200 Subject: [PATCH 1099/1118] Implement snapshot testing for Plugwise binary_sensor platform (#150375) --- .../snapshots/test_binary_sensor.ambr | 947 ++++++++++++++++++ .../components/plugwise/test_binary_sensor.py | 75 +- 2 files changed, 982 insertions(+), 40 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_binary_sensor.ambr diff --git a/tests/components/plugwise/snapshots/test_binary_sensor.ambr b/tests/components/plugwise/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..d371bb38803 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_binary_sensor.ambr @@ -0,0 +1,947 @@ +# serializer version: 1 +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.adam_plugwise_notification-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.adam_plugwise_notification', + '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': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': 'fe799307f1624099878210aa0b9f1475-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.adam_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Adam Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.adam_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.bios_cv_thermostatic_radiator_battery-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.bios_cv_thermostatic_radiator_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2c3583e0a6349358998b760cea82d2a-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.bios_cv_thermostatic_radiator_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bios Cv Thermostatic Radiator Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.bios_cv_thermostatic_radiator_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.cv_kraan_garage_battery-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.cv_kraan_garage_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e7693eb9582644e5b865dba8d4447cf1-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.cv_kraan_garage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'CV Kraan Garage Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.cv_kraan_garage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.onoff_heating-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.onoff_heating', + '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': 'Heating', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_state', + 'unique_id': '90986d591dcd426cae3ec3e8111ff730-heating_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.onoff_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OnOff Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.onoff_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_1_battery-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.thermostatic_radiator_badkamer_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '680423ff840043738f42cc7f1ff97a36-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 1 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_2_battery-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.thermostatic_radiator_badkamer_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f1fee6043d3642a9b0a65297455f008e-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_badkamer_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Badkamer 2 Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_badkamer_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_jessie_battery-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.thermostatic_radiator_jessie_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd3da73bde12a47d5a6b8f9dad971f2ec-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.thermostatic_radiator_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Thermostatic Radiator Jessie Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.thermostatic_radiator_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_bios_battery-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.zone_lisa_bios_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'df4a4a8169904cdb9c03d61a21f42140-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_bios_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa Bios Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_lisa_bios_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_wk_battery-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.zone_lisa_wk_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b59bcebaf94b499ea7d46e4a66fb62d8-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_lisa_wk_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Lisa WK Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_lisa_wk_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_thermostat_jessie_battery-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.zone_thermostat_jessie_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': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6a3bf693d05e48e0b460c815a4fdd09d-low_battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_binary_sensor_snapshot[platforms0][binary_sensor.zone_thermostat_jessie_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Zone Thermostat Jessie Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_thermostat_jessie_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_compressor_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': , + 'entity_id': 'binary_sensor.opentherm_compressor_state', + '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': 'Compressor state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'compressor_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-compressor_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_compressor_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Compressor state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_compressor_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling-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.opentherm_cooling', + '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': 'Cooling', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-cooling_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_cooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling_enabled-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.opentherm_cooling_enabled', + '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': 'Cooling enabled', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_enabled', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-cooling_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_cooling_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Cooling enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_cooling_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_dhw_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': , + 'entity_id': 'binary_sensor.opentherm_dhw_state', + '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': 'DHW state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-dhw_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_dhw_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm DHW state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_dhw_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_flame_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': , + 'entity_id': 'binary_sensor.opentherm_flame_state', + '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': 'Flame state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flame_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-flame_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_flame_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Flame state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_flame_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_heating-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.opentherm_heating', + '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': 'Heating', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-heating_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Heating', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_secondary_boiler_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': , + 'entity_id': 'binary_sensor.opentherm_secondary_boiler_state', + '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': 'Secondary boiler state', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'secondary_boiler_state', + 'unique_id': '1cbf783bb11e4a7c8a6843dee3a86927-secondary_boiler_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.opentherm_secondary_boiler_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OpenTherm Secondary boiler state', + }), + 'context': , + 'entity_id': 'binary_sensor.opentherm_secondary_boiler_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.smile_anna_plugwise_notification-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.smile_anna_plugwise_notification', + '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': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': '015ae9ea3f964e668e490fa39da3870b-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_anna_binary_sensor_snapshot[platforms0-True-anna_heatpump_heating][binary_sensor.smile_anna_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Smile Anna Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.smile_anna_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_p1_v4_binary_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][binary_sensor.smile_p1_plugwise_notification-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.smile_p1_plugwise_notification', + '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': 'Plugwise notification', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plugwise_notification', + 'unique_id': '03e65b16e4b247a29ae0d75a78cb492e-plugwise_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_p1_v4_binary_sensor_snapshot[platforms0-03e65b16e4b247a29ae0d75a78cb492e-p1v4_442_triple][binary_sensor.smile_p1_plugwise_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_msg': list([ + ]), + 'friendly_name': 'Smile P1 Plugwise notification', + 'info_msg': list([ + ]), + 'other_msg': list([ + ]), + 'warning_msg': list([ + 'The Smile P1 is not connected to a smart meter.', + ]), + }), + 'context': , + 'entity_id': 'binary_sensor.smile_p1_plugwise_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 7bf475086af..c01da5c5205 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -3,36 +3,43 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_binary_sensor_snapshot( + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -@pytest.mark.parametrize( - ("entity_id", "expected_state"), - [ - ("binary_sensor.opentherm_secondary_boiler_state", STATE_OFF), - ("binary_sensor.opentherm_dhw_state", STATE_OFF), - ("binary_sensor.opentherm_heating", STATE_ON), - ("binary_sensor.opentherm_cooling_enabled", STATE_OFF), - ("binary_sensor.opentherm_compressor_state", STATE_ON), - ], -) -async def test_anna_climate_binary_sensor_entities( +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_anna_binary_sensor_snapshot( hass: HomeAssistant, mock_smile_anna: MagicMock, - init_integration: MockConfigEntry, - entity_id: str, - expected_state: str, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test creation of climate related binary_sensor entities.""" - state = hass.states.get(entity_id) - assert state.state == expected_state + """Test Anna binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @@ -49,35 +56,23 @@ async def test_anna_climate_binary_sensor_change( assert state.state == STATE_ON await async_update_entity(hass, "binary_sensor.opentherm_dhw_state") - state = hass.states.get("binary_sensor.opentherm_dhw_state") assert state assert state.state == STATE_OFF -async def test_adam_climate_binary_sensor_change( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry -) -> None: - """Test of a climate related plugwise-notification binary_sensor.""" - state = hass.states.get("binary_sensor.adam_plugwise_notification") - assert state - assert state.state == STATE_ON - assert "warning_msg" in state.attributes - assert "unreachable" in state.attributes["warning_msg"][0] - assert not state.attributes.get("error_msg") - assert not state.attributes.get("other_msg") - - @pytest.mark.parametrize("chosen_env", ["p1v4_442_triple"], indirect=True) @pytest.mark.parametrize( "gateway_id", ["03e65b16e4b247a29ae0d75a78cb492e"], indirect=True ) -async def test_p1_binary_sensor_entity( - hass: HomeAssistant, mock_smile_p1: MagicMock, init_integration: MockConfigEntry +@pytest.mark.parametrize("platforms", [(BINARY_SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_p1_v4_binary_sensor_snapshot( + hass: HomeAssistant, + mock_smile_p1: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test of a Smile P1 related plugwise-notification binary_sensor.""" - state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") - assert state - assert state.state == STATE_ON - assert "warning_msg" in state.attributes - assert "connected" in state.attributes["warning_msg"][0] + """Test Smile P1 binary_sensor snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 531073acc027042edb1d1b132a47f3014d62910e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Aug 2025 13:12:29 +0200 Subject: [PATCH 1100/1118] Allow specifying multiple integrations (#150349) --- script/install_integration_requirements.py | 48 +++++++++++++--------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index 91c9f6a8ed0..74fd1c93be5 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -1,4 +1,4 @@ -"""Install requirements for a given integration.""" +"""Install requirements for one or more integrations.""" import argparse from pathlib import Path @@ -12,39 +12,49 @@ from .util import valid_integration def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser( - description="Install requirements for a given integration" + description="Install requirements for one or more integrations" ) parser.add_argument( - "integration", type=valid_integration, help="Integration to target." + "integrations", + nargs="+", + type=valid_integration, + help="Integration(s) to target.", ) return parser.parse_args() def main() -> int | None: - """Install requirements for a given integration.""" + """Install requirements for the specified integrations.""" if not Path("requirements_all.txt").is_file(): print("Run from project root") return 1 args = get_arguments() - requirements = gather_recursive_requirements(args.integration) + # Gather requirements for all specified integrations + all_requirements = set() + for integration in args.integrations: + requirements = gather_recursive_requirements(integration) + all_requirements.update(requirements) - cmd = [ - "uv", - "pip", - "install", - "-c", - "homeassistant/package_constraints.txt", - "-U", - *requirements, - ] - print(" ".join(cmd)) - subprocess.run( - cmd, - check=True, - ) + if all_requirements: + cmd = [ + "uv", + "pip", + "install", + "-c", + "homeassistant/package_constraints.txt", + "-U", + *sorted(all_requirements), # Sort for consistent output + ] + print(" ".join(cmd)) + subprocess.run( + cmd, + check=True, + ) + else: + print("No requirements to install.") return None From d54f9796121f2f543ee6c90ec1b884037d0a4a79 Mon Sep 17 00:00:00 2001 From: "Etienne C." <59794011+etiennec78@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:20:18 +0200 Subject: [PATCH 1101/1118] Add a coordinator to Waze Travel Time (#148585) --- .../components/waze_travel_time/__init__.py | 121 +-------- .../waze_travel_time/coordinator.py | 245 ++++++++++++++++++ .../components/waze_travel_time/sensor.py | 170 ++---------- tests/components/waze_travel_time/conftest.py | 2 +- .../waze_travel_time/test_config_flow.py | 12 +- .../components/waze_travel_time/test_init.py | 8 +- .../waze_travel_time/test_sensor.py | 3 +- 7 files changed, 293 insertions(+), 268 deletions(-) create mode 100644 homeassistant/components/waze_travel_time/coordinator.py diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 3a91690ef07..2e719a41a21 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,15 +1,13 @@ """The waze_travel_time component.""" import asyncio -from collections.abc import Collection import logging -from typing import Literal -from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError +from pywaze.route_calculator import WazeRouteCalculator import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_REGION, Platform, UnitOfLength +from homeassistant.const import CONF_REGION, Platform from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -27,7 +25,6 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) -from homeassistant.util.unit_conversion import DistanceConverter from .const import ( CONF_AVOID_FERRIES, @@ -43,13 +40,13 @@ from .const import ( DEFAULT_FILTER, DEFAULT_VEHICLE_TYPE, DOMAIN, - IMPERIAL_UNITS, METRIC_UNITS, REGIONS, SEMAPHORE, UNITS, VEHICLE_TYPES, ) +from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times PLATFORMS = [Platform.SENSOR] @@ -109,6 +106,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) + httpx_client = get_async_client(hass) + client = WazeRouteCalculator( + region=config_entry.data[CONF_REGION].upper(), client=httpx_client + ) + + coordinator = WazeTravelTimeCoordinator(hass, config_entry, client) + config_entry.runtime_data = coordinator + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse: @@ -140,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b incl_filters=service.data.get(CONF_INCL_FILTER, DEFAULT_FILTER), excl_filters=service.data.get(CONF_EXCL_FILTER, DEFAULT_FILTER), ) - return {"routes": [vars(route) for route in response]} if response else None + return {"routes": [vars(route) for route in response]} hass.services.async_register( DOMAIN, @@ -152,106 +159,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_get_travel_times( - client: WazeRouteCalculator, - origin: str, - destination: str, - vehicle_type: str, - avoid_toll_roads: bool, - avoid_subscription_roads: bool, - avoid_ferries: bool, - realtime: bool, - units: Literal["metric", "imperial"] = "metric", - incl_filters: Collection[str] | None = None, - excl_filters: Collection[str] | None = None, -) -> list[CalcRoutesResponse] | None: - """Get all available routes.""" - - incl_filters = incl_filters or () - excl_filters = excl_filters or () - - _LOGGER.debug( - "Getting update for origin: %s destination: %s", - origin, - destination, - ) - routes = [] - vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() - try: - routes = await client.calc_routes( - origin, - destination, - vehicle_type=vehicle_type, - avoid_toll_roads=avoid_toll_roads, - avoid_subscription_roads=avoid_subscription_roads, - avoid_ferries=avoid_ferries, - real_time=realtime, - alternatives=3, - ) - _LOGGER.debug("Got routes: %s", routes) - - incl_routes: list[CalcRoutesResponse] = [] - - def should_include_route(route: CalcRoutesResponse) -> bool: - if len(incl_filters) < 1: - return True - should_include = any( - street_name in incl_filters or "" in incl_filters - for street_name in route.street_names - ) - if not should_include: - _LOGGER.debug( - "Excluding route [%s], because no inclusive filter matched any streetname", - route.name, - ) - return False - return True - - incl_routes = [route for route in routes if should_include_route(route)] - - filtered_routes: list[CalcRoutesResponse] = [] - - def should_exclude_route(route: CalcRoutesResponse) -> bool: - for street_name in route.street_names: - for excl_filter in excl_filters: - if excl_filter == street_name: - _LOGGER.debug( - "Excluding route, because exclusive filter [%s] matched streetname: %s", - excl_filter, - route.name, - ) - return True - return False - - filtered_routes = [ - route for route in incl_routes if not should_exclude_route(route) - ] - - if units == IMPERIAL_UNITS: - filtered_routes = [ - CalcRoutesResponse( - name=route.name, - distance=DistanceConverter.convert( - route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES - ), - duration=route.duration, - street_names=route.street_names, - ) - for route in filtered_routes - if route.distance is not None - ] - - if len(filtered_routes) < 1: - _LOGGER.warning("No routes found") - return None - except WRCError as exp: - _LOGGER.warning("Error on retrieving data: %s", exp) - return None - - else: - return filtered_routes - - async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/waze_travel_time/coordinator.py b/homeassistant/components/waze_travel_time/coordinator.py new file mode 100644 index 00000000000..23dfea86ed2 --- /dev/null +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -0,0 +1,245 @@ +"""The Waze Travel Time data coordinator.""" + +import asyncio +from collections.abc import Collection +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Literal + +from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.location import find_coordinates +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import ( + CONF_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS, + CONF_DESTINATION, + CONF_EXCL_FILTER, + CONF_INCL_FILTER, + CONF_ORIGIN, + CONF_REALTIME, + CONF_UNITS, + CONF_VEHICLE_TYPE, + DOMAIN, + IMPERIAL_UNITS, + SEMAPHORE, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=5) + +SECONDS_BETWEEN_API_CALLS = 0.5 + + +async def async_get_travel_times( + client: WazeRouteCalculator, + origin: str, + destination: str, + vehicle_type: str, + avoid_toll_roads: bool, + avoid_subscription_roads: bool, + avoid_ferries: bool, + realtime: bool, + units: Literal["metric", "imperial"] = "metric", + incl_filters: Collection[str] | None = None, + excl_filters: Collection[str] | None = None, +) -> list[CalcRoutesResponse]: + """Get all available routes.""" + + incl_filters = incl_filters or () + excl_filters = excl_filters or () + + _LOGGER.debug( + "Getting update for origin: %s destination: %s", + origin, + destination, + ) + routes = [] + vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() + try: + routes = await client.calc_routes( + origin, + destination, + vehicle_type=vehicle_type, + avoid_toll_roads=avoid_toll_roads, + avoid_subscription_roads=avoid_subscription_roads, + avoid_ferries=avoid_ferries, + real_time=realtime, + alternatives=3, + ) + + if len(routes) < 1: + _LOGGER.warning("No routes found") + return routes + + _LOGGER.debug("Got routes: %s", routes) + + incl_routes: list[CalcRoutesResponse] = [] + + def should_include_route(route: CalcRoutesResponse) -> bool: + if len(incl_filters) < 1: + return True + should_include = any( + street_name in incl_filters or "" in incl_filters + for street_name in route.street_names + ) + if not should_include: + _LOGGER.debug( + "Excluding route [%s], because no inclusive filter matched any streetname", + route.name, + ) + return False + return True + + incl_routes = [route for route in routes if should_include_route(route)] + + filtered_routes: list[CalcRoutesResponse] = [] + + def should_exclude_route(route: CalcRoutesResponse) -> bool: + for street_name in route.street_names: + for excl_filter in excl_filters: + if excl_filter == street_name: + _LOGGER.debug( + "Excluding route, because exclusive filter [%s] matched streetname: %s", + excl_filter, + route.name, + ) + return True + return False + + filtered_routes = [ + route for route in incl_routes if not should_exclude_route(route) + ] + + if len(filtered_routes) < 1: + _LOGGER.warning("No routes matched your filters") + return filtered_routes + + if units == IMPERIAL_UNITS: + filtered_routes = [ + CalcRoutesResponse( + name=route.name, + distance=DistanceConverter.convert( + route.distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES + ), + duration=route.duration, + street_names=route.street_names, + ) + for route in filtered_routes + if route.distance is not None + ] + + except WRCError as exp: + raise UpdateFailed(f"Error on retrieving data: {exp}") from exp + + else: + return filtered_routes + + +@dataclass +class WazeTravelTimeData: + """WazeTravelTime data class.""" + + origin: str + destination: str + duration: float | None + distance: float | None + route: str | None + + +class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): + """Waze Travel Time DataUpdateCoordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: WazeRouteCalculator, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self._origin = config_entry.data[CONF_ORIGIN] + self._destination = config_entry.data[CONF_DESTINATION] + + async def _async_update_data(self) -> WazeTravelTimeData: + """Get the latest data from Waze.""" + origin_coordinates = find_coordinates(self.hass, self._origin) + destination_coordinates = find_coordinates(self.hass, self._destination) + + _LOGGER.debug( + "Fetching Route for %s, from %s to %s", + self.config_entry.title, + self._origin, + self._destination, + ) + await self.hass.data[DOMAIN][SEMAPHORE].acquire() + try: + if origin_coordinates is None or destination_coordinates is None: + raise UpdateFailed("Unable to determine origin or destination") + + # Grab options on every update + incl_filter = self.config_entry.options[CONF_INCL_FILTER] + excl_filter = self.config_entry.options[CONF_EXCL_FILTER] + realtime = self.config_entry.options[CONF_REALTIME] + vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] + avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] + avoid_subscription_roads = self.config_entry.options[ + CONF_AVOID_SUBSCRIPTION_ROADS + ] + avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] + routes = await async_get_travel_times( + self.client, + origin_coordinates, + destination_coordinates, + vehicle_type, + avoid_toll_roads, + avoid_subscription_roads, + avoid_ferries, + realtime, + self.config_entry.options[CONF_UNITS], + incl_filter, + excl_filter, + ) + if len(routes) < 1: + travel_data = WazeTravelTimeData( + origin=origin_coordinates, + destination=destination_coordinates, + duration=None, + distance=None, + route=None, + ) + + else: + route = routes[0] + + travel_data = WazeTravelTimeData( + origin=origin_coordinates, + destination=destination_coordinates, + duration=route.duration, + distance=route.distance, + route=route.name, + ) + + await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) + + finally: + self.hass.data[DOMAIN][SEMAPHORE].release() + + return travel_data diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 1f21cc2ea78..e56edfae53d 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -2,56 +2,22 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging from typing import Any -import httpx -from pywaze.route_calculator import WazeRouteCalculator - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_REGION, - EVENT_HOMEASSISTANT_STARTED, - UnitOfTime, -) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.const import CONF_NAME, UnitOfTime +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.location import find_coordinates +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import async_get_travel_times -from .const import ( - CONF_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS, - CONF_DESTINATION, - CONF_EXCL_FILTER, - CONF_INCL_FILTER, - CONF_ORIGIN, - CONF_REALTIME, - CONF_UNITS, - CONF_VEHICLE_TYPE, - DEFAULT_NAME, - DOMAIN, - SEMAPHORE, -) - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=5) - -PARALLEL_UPDATES = 1 - -SECONDS_BETWEEN_API_CALLS = 0.5 +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import WazeTravelTimeCoordinator async def async_setup_entry( @@ -60,23 +26,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" - destination = config_entry.data[CONF_DESTINATION] - origin = config_entry.data[CONF_ORIGIN] - region = config_entry.data[CONF_REGION] name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + coordinator = config_entry.runtime_data - data = WazeTravelTimeData( - region, - get_async_client(hass), - config_entry, - ) - - sensor = WazeTravelTime(config_entry.entry_id, name, origin, destination, data) + sensor = WazeTravelTimeSensor(config_entry.entry_id, name, coordinator) async_add_entities([sensor], False) -class WazeTravelTime(SensorEntity): +class WazeTravelTimeSensor(CoordinatorEntity[WazeTravelTimeCoordinator], SensorEntity): """Representation of a Waze travel time sensor.""" _attr_attribution = "Powered by Waze" @@ -95,119 +53,33 @@ class WazeTravelTime(SensorEntity): self, unique_id: str, name: str, - origin: str, - destination: str, - waze_data: WazeTravelTimeData, + coordinator: WazeTravelTimeCoordinator, ) -> None: """Initialize the Waze travel time sensor.""" + super().__init__(coordinator) self._attr_unique_id = unique_id - self._waze_data = waze_data self._attr_name = name - self._origin = origin - self._destination = destination - self._state = None - - async def async_added_to_hass(self) -> None: - """Handle when entity is added.""" - if self.hass.state is not CoreState.running: - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self.first_update - ) - else: - await self.first_update() @property def native_value(self) -> float | None: """Return the state of the sensor.""" - if self._waze_data.duration is not None: - return round(self._waze_data.duration) - + if ( + self.coordinator.data is not None + and self.coordinator.data.duration is not None + ): + return round(self.coordinator.data.duration) return None @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the last update.""" - if self._waze_data.duration is None: + if self.coordinator.data is None: return None return { - "duration": self._waze_data.duration, - "distance": self._waze_data.distance, - "route": self._waze_data.route, - "origin": self._waze_data.origin, - "destination": self._waze_data.destination, + "duration": self.coordinator.data.duration, + "distance": self.coordinator.data.distance, + "route": self.coordinator.data.route, + "origin": self.coordinator.data.origin, + "destination": self.coordinator.data.destination, } - - async def first_update(self, _=None) -> None: - """Run first update and write state.""" - await self.async_update() - self.async_write_ha_state() - - async def async_update(self) -> None: - """Fetch new state data for the sensor.""" - _LOGGER.debug("Fetching Route for %s", self._attr_name) - self._waze_data.origin = find_coordinates(self.hass, self._origin) - self._waze_data.destination = find_coordinates(self.hass, self._destination) - await self.hass.data[DOMAIN][SEMAPHORE].acquire() - try: - await self._waze_data.async_update() - await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) - finally: - self.hass.data[DOMAIN][SEMAPHORE].release() - - -class WazeTravelTimeData: - """WazeTravelTime Data object.""" - - def __init__( - self, region: str, client: httpx.AsyncClient, config_entry: ConfigEntry - ) -> None: - """Set up WazeRouteCalculator.""" - self.config_entry = config_entry - self.client = WazeRouteCalculator(region=region, client=client) - self.origin: str | None = None - self.destination: str | None = None - self.duration = None - self.distance = None - self.route = None - - async def async_update(self): - """Update WazeRouteCalculator Sensor.""" - _LOGGER.debug( - "Getting update for origin: %s destination: %s", - self.origin, - self.destination, - ) - if self.origin is not None and self.destination is not None: - # Grab options on every update - incl_filter = self.config_entry.options[CONF_INCL_FILTER] - excl_filter = self.config_entry.options[CONF_EXCL_FILTER] - realtime = self.config_entry.options[CONF_REALTIME] - vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] - avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] - avoid_subscription_roads = self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ] - avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] - routes = await async_get_travel_times( - self.client, - self.origin, - self.destination, - vehicle_type, - avoid_toll_roads, - avoid_subscription_roads, - avoid_ferries, - realtime, - self.config_entry.options[CONF_UNITS], - incl_filter, - excl_filter, - ) - if routes: - route = routes[0] - else: - _LOGGER.warning("No routes found") - return - - self.duration = route.duration - self.distance = route.distance - self.route = route.name diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index c9214ed8b71..fbaa7519ea8 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -53,7 +53,7 @@ def mock_update_fixture(): @pytest.fixture(name="validate_config_entry") def validate_config_entry_fixture(mock_update): """Return valid config entry.""" - mock_update.return_value = None + mock_update.return_value = [] return mock_update diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index 9ff7509a52c..da718a98983 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -116,8 +116,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -129,8 +129,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -140,8 +140,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: ["exclude"], - CONF_INCL_FILTER: ["include"], + CONF_EXCL_FILTER: ["ExcludeThis"], + CONF_INCL_FILTER: ["IncludeThis"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index 89bccc00985..d11bca524e9 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -101,8 +101,8 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, - CONF_INCL_FILTER: "include", - CONF_EXCL_FILTER: "exclude", + CONF_INCL_FILTER: "IncludeThis", + CONF_EXCL_FILTER: "ExcludeThis", }, ) @@ -114,5 +114,5 @@ async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: assert updated_entry.state is ConfigEntryState.LOADED assert updated_entry.version == 2 - assert updated_entry.options[CONF_INCL_FILTER] == ["include"] - assert updated_entry.options[CONF_EXCL_FILTER] == ["exclude"] + assert updated_entry.options[CONF_INCL_FILTER] == ["IncludeThis"] + assert updated_entry.options[CONF_EXCL_FILTER] == ["ExcludeThis"] diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index 94e3a0cf9d7..0aa99196c48 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.waze_travel_time.const import ( IMPERIAL_UNITS, METRIC_UNITS, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG @@ -153,5 +154,5 @@ async def test_sensor_failed_wrcerror( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("sensor.waze_travel_time").state == "unknown" + assert config_entry.state is ConfigEntryState.SETUP_RETRY assert "Error on retrieving data: " in caplog.text From 9595759fd18d5051941634f78e75f1178027dc2f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 11 Aug 2025 21:54:44 +1000 Subject: [PATCH 1102/1118] Add stale device cleanup to Teslemetry (#144523) --- .../components/teslemetry/__init__.py | 22 +++++ tests/components/teslemetry/test_init.py | 94 ++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 688a254a731..af4ce26a0cc 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -97,6 +97,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Create the stream stream: TeslemetryStream | None = None + # Remember each device identifier we create + current_devices: set[tuple[str, str]] = set() + for product in products: if ( "vin" in product @@ -116,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - model=api.model, serial_number=vin, ) + current_devices.add((DOMAIN, vin)) # Create stream if required if not stream: @@ -171,6 +175,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - name=product.get("site_name", "Energy Site"), serial_number=str(site_id), ) + current_devices.add((DOMAIN, str(site_id))) + + if wall_connector: + for connector in product["components"]["wall_connectors"]: + current_devices.add((DOMAIN, connector["din"])) # Check live status endpoint works before creating its coordinator try: @@ -235,6 +244,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - config_entry_id=entry.entry_id, **energysite.device ) + # Remove devices that are no longer present + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if not any( + identifier in current_devices for identifier in device_entry.identifiers + ): + LOGGER.debug("Removing stale device %s", device_entry.id) + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=entry.entry_id, + ) + # Setup Platforms entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index e177865d2f9..00e8d54c9fe 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,6 +1,6 @@ """Test the Teslemetry init.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,6 +11,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState @@ -187,3 +188,94 @@ async def test_modern_no_poll( assert mock_vehicle_data.called is False freezer.tick(VEHICLE_INTERVAL) assert mock_vehicle_data.called is False + + +async def test_stale_device_removal( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test removal of stale devices.""" + + # Setup the entry first to get a valid config_entry_id + entry = await setup_platform(hass) + + # Create a device that should be removed (with the valid entry_id) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "stale-vin")}, + manufacturer="Tesla", + name="Stale Vehicle", + ) + + # Verify the stale device exists + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + stale_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + assert (DOMAIN, "stale-vin") in stale_identifiers + + # Update products with an empty response (no devices) and reload entry + with patch( + "tesla_fleet_api.teslemetry.Teslemetry.products", + return_value={"response": []}, + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Get updated devices after reload + post_devices = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Verify the stale device has been removed + assert (DOMAIN, "stale-vin") not in post_identifiers + + # Verify the device itself has been completely removed from the registry + # since it had no other config entries + updated_device = device_registry.async_get_device( + identifiers={(DOMAIN, "stale-vin")} + ) + assert updated_device is None + + +async def test_device_retention_during_reload( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test that valid devices are retained during a config entry reload.""" + # Setup entry with normal devices + entry = await setup_platform(hass) + + # Get initial device count and identifiers + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + pre_count = len(pre_devices) + pre_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + + # Make sure we have some devices + assert pre_count > 0 + + # Save the original identifiers to compare after reload + original_identifiers = pre_identifiers.copy() + + # Reload the config entry with the same products data + # The mock_products fixture will return the same data as during setup + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Verify device count and identifiers after reload match pre-reload + post_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + post_count = len(post_devices) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Since the products data didn't change, we should have the same devices + assert post_count == pre_count + assert post_identifiers == original_identifiers From d135d088139096cfff60be0fb9d4bf277f93f92d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 11 Aug 2025 14:09:04 +0200 Subject: [PATCH 1103/1118] Lower Z-Wave firmware check delay (#150411) --- homeassistant/components/zwave_js/update.py | 13 ++++----- tests/components/zwave_js/test_discovery.py | 12 ++++++++- tests/components/zwave_js/test_update.py | 30 ++++++++++----------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 88e1a22c00f..9e9d6ee2ef3 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -42,7 +42,7 @@ from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 1 UPDATE_DELAY_STRING = "delay" -UPDATE_DELAY_INTERVAL = 5 # In minutes +UPDATE_DELAY_INTERVAL = 15 # In seconds ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" @@ -129,11 +129,11 @@ async def async_setup_entry( @callback def async_add_firmware_update_entity(node: ZwaveNode) -> None: """Add firmware update entity.""" - # We need to delay the first update of each entity to avoid flooding the network - # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL - # minute increments. + # Delay the first update of each entity to avoid spamming the firmware server. + # Maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL + # second increments. cnt[UPDATE_DELAY_STRING] += 1 - delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) + delay = timedelta(seconds=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. if node.is_controller_node: @@ -413,7 +413,8 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): ): self._attr_latest_version = self._attr_installed_version - # Spread updates out in 5 minute increments to avoid flooding the network + # Spread updates out in 15 second increments + # to avoid spamming the firmware server self.async_on_remove( async_call_later(self.hass, self._delay, self._async_update) ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 9109d6a4048..6a4752d536b 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -28,7 +28,13 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_UNKNOWN, + EntityCategory, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -253,6 +259,7 @@ async def test_merten_507801_disabled_enitites( assert updated_entry.disabled is False +@pytest.mark.parametrize("platforms", [[Platform.BUTTON, Platform.NUMBER]]) async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -324,6 +331,9 @@ async def test_zooz_zen72( assert args["value"] is True +@pytest.mark.parametrize( + "platforms", [[Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]] +) async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index d7243268b9e..b78d202935d 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -167,7 +167,7 @@ async def test_update_entity_states( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -186,7 +186,7 @@ async def test_update_entity_states( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=2)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -224,7 +224,7 @@ async def test_update_entity_states( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=3)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -246,7 +246,7 @@ async def test_update_entity_install_raises( """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() # Test failed installation by driver @@ -279,7 +279,7 @@ async def test_update_entity_sleep( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() # Two nodes in total, the controller node and the zen_31 node. @@ -308,7 +308,7 @@ async def test_update_entity_dead( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() # Two nodes in total, the controller node and the zen_31 node. @@ -352,14 +352,14 @@ async def test_update_entity_ha_not_running( # Update should be delayed by a day because Home Assistant is not running hass.set_state(CoreState.starting) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15)) await hass.async_block_till_done() assert client.async_send_command.call_count == 0 hass.set_state(CoreState.running) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() # Two nodes in total, the controller node and the zen_31 node. @@ -385,7 +385,7 @@ async def test_update_entity_update_failure( assert client.async_send_command.call_count == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() entity_ids = (CONTROLLER_UPDATE_ENTITY, NODE_UPDATE_ENTITY) @@ -493,7 +493,7 @@ async def test_update_entity_progress( client.async_send_command.return_value = FIRMWARE_UPDATES driver = client.driver - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -641,7 +641,7 @@ async def test_update_entity_install_failed( driver = client.driver client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -717,7 +717,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=1)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -726,7 +726,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=2)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -758,7 +758,7 @@ async def test_update_entity_reload( await hass.async_block_till_done() # Trigger another update and make sure the skipped version is still skipped - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15, days=4)) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -793,7 +793,7 @@ async def test_update_entity_delay( assert client.async_send_command.call_count == 0 - update_interval = timedelta(minutes=5) + update_interval = timedelta(seconds=15) freezer.tick(update_interval) async_fire_time_changed(hass) await hass.async_block_till_done() From a1dc3f3eacfdac0a8de042cf79d46e7a4b6425c7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:51:22 +0200 Subject: [PATCH 1104/1118] Bump habiticalib to version 0.4.2 (#150417) --- homeassistant/components/habitica/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index d890ed23676..e0c58383bcc 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["habiticalib"], "quality_scale": "platinum", - "requirements": ["habiticalib==0.4.1"] + "requirements": ["habiticalib==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3588839c48..1bd2631b36d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.1 +habiticalib==0.4.2 # homeassistant.components.bluetooth habluetooth==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d93ec1ff94..0f2177be210 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ ha-philipsjs==3.2.2 ha-silabs-firmware-client==0.2.0 # homeassistant.components.habitica -habiticalib==0.4.1 +habiticalib==0.4.2 # homeassistant.components.bluetooth habluetooth==5.0.1 From 7688c367cc6eaffeda8be5ac24eec5436fa54408 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 11 Aug 2025 07:58:36 -0700 Subject: [PATCH 1105/1118] Remove coinbase v2 API support (#148387) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/coinbase/__init__.py | 78 ++----- .../components/coinbase/config_flow.py | 82 +++++-- .../components/coinbase/manifest.json | 2 +- homeassistant/components/coinbase/sensor.py | 14 +- .../components/coinbase/strings.json | 11 +- requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/coinbase/common.py | 30 +-- tests/components/coinbase/test_config_flow.py | 208 +++++++++--------- tests/components/coinbase/test_diagnostics.py | 14 +- tests/components/coinbase/test_init.py | 92 ++++++-- 11 files changed, 291 insertions(+), 246 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 317759f820d..adb6dc48c9c 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -7,12 +7,11 @@ import logging from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.util import Throttle @@ -20,9 +19,7 @@ from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_AMOUNT, API_ACCOUNT_AVALIABLE, - API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, - API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_HOLD, API_ACCOUNT_ID, API_ACCOUNT_NAME, @@ -31,7 +28,6 @@ from .const import ( API_DATA, API_RATES_CURRENCY, API_RESOURCE_TYPE, - API_TYPE_VAULT, API_V3_ACCOUNT_ID, API_V3_TYPE_VAULT, CONF_CURRENCIES, @@ -68,16 +64,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" + + # Check if user is using deprecated v2 API credentials if "organizations" not in entry.data[CONF_API_KEY]: - client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) - version = "v2" - else: - client = RESTClient( - api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + # Trigger reauthentication to ask user for v3 credentials + raise ConfigEntryAuthFailed( + "Your Coinbase API key appears to be for the deprecated v2 API. " + "Please reconfigure with a new API key created for the v3 API. " + "Visit https://www.coinbase.com/developer-platform to create new credentials." ) - version = "v3" + + client = RESTClient( + api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + ) base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") - instance = CoinbaseData(client, base_rate, version) + instance = CoinbaseData(client, base_rate) instance.update() return instance @@ -105,31 +106,9 @@ async def update_listener( registry.async_remove(entity.entity_id) -def get_accounts(client, version): +def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() - if version == "v2": - accounts = response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - while next_starting_after: - response = client.get_accounts(starting_after=next_starting_after) - accounts += response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - return [ - { - API_ACCOUNT_ID: account[API_ACCOUNT_ID], - API_ACCOUNT_NAME: account[API_ACCOUNT_NAME], - API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][ - API_ACCOUNT_CURRENCY_CODE - ], - API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT], - ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT, - } - for account in accounts - ] - accounts = response[API_ACCOUNTS] while response["has_next"]: response = client.get_accounts(cursor=response["cursor"]) @@ -153,37 +132,28 @@ def get_accounts(client, version): class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, client, exchange_base, version): + def __init__(self, client, exchange_base): """Init the coinbase data object.""" self.client = client self.accounts = None self.exchange_base = exchange_base self.exchange_rates = None - if version == "v2": - self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] - else: - self.user_id = ( - "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] - ) - self.api_version = version + self.user_id = ( + "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from coinbase.""" try: - self.accounts = get_accounts(self.client, self.api_version) - if self.api_version == "v2": - self.exchange_rates = self.client.get_exchange_rates( - currency=self.exchange_base - ) - else: - self.exchange_rates = self.client.get( - "/v2/exchange-rates", - params={API_RATES_CURRENCY: self.exchange_base}, - )[API_DATA] - except (AuthenticationError, HTTPError) as coinbase_error: + self.accounts = get_accounts(self.client) + self.exchange_rates = self.client.get( + "/v2/exchange-rates", + params={API_RATES_CURRENCY: self.exchange_base}, + )[API_DATA] + except HTTPError as coinbase_error: _LOGGER.error( "Authentication error connecting to coinbase: %s", coinbase_error ) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 3234ec29679..e1dad899d2b 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -2,17 +2,16 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -45,9 +44,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( def get_user_from_client(api_key, api_token): """Get the user name from Coinbase API credentials.""" - if "organizations" not in api_key: - client = LegacyClient(api_key, api_token) - return client.get_current_user()["name"] client = RESTClient(api_key=api_key, api_secret=api_token) return client.get_portfolios()["portfolios"][0]["name"] @@ -59,7 +55,7 @@ async def validate_api(hass: HomeAssistant, data): user = await hass.async_add_executor_job( get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] ) - except (AuthenticationError, HTTPError) as error: + except HTTPError as error: if "api key" in str(error) or " 401 Client Error" in str(error): _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key") raise InvalidKey from error @@ -74,8 +70,8 @@ async def validate_api(hass: HomeAssistant, data): raise InvalidAuth from error except ConnectionError as error: raise CannotConnect from error - api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2" - return {"title": user, "api_version": api_version} + + return {"title": user} async def validate_options( @@ -85,20 +81,17 @@ async def validate_options( client = config_entry.runtime_data.client - accounts = await hass.async_add_executor_job( - get_accounts, client, config_entry.data.get("api_version", "v2") - ) + accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ account[API_ACCOUNT_CURRENCY] for account in accounts if not account[ACCOUNT_IS_VAULT] ] - if config_entry.data.get("api_version", "v2") == "v2": - available_rates = await hass.async_add_executor_job(client.get_exchange_rates) - else: - resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") - available_rates = resp[API_DATA] + + resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") + available_rates = resp[API_DATA] + if CONF_CURRENCIES in options: for currency in options[CONF_CURRENCIES]: if currency not in accounts_currencies: @@ -117,6 +110,8 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + reauth_entry: CoinbaseConfigEntry + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -143,12 +138,63 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_API_VERSION] = info["api_version"] return self.async_create_entry(title=info["title"], 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: + """Handle reauthentication flow.""" + self.reauth_entry = self._get_reauth_entry() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + + try: + await validate_api(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidKey: + errors["base"] = "invalid_auth_key" + except InvalidSecret: + errors["base"] = "invalid_auth_secret" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.reauth_entry, + data_updates=user_input, + reason="reauth_successful", + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index be632b5e856..fcd48f9e91d 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/coinbase", "iot_class": "cloud_polling", "loggers": ["coinbase"], - "requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"] + "requirements": ["coinbase-advanced-py==1.2.2"] } diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 578877e7d90..f69aed8c386 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -27,7 +27,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) ATTR_NATIVE_BALANCE = "Balance in native currency" -ATTR_API_VERSION = "API Version" CURRENCY_ICONS = { "BTC": "mdi:currency-btc", @@ -71,9 +70,8 @@ async def async_setup_entry( for currency in desired_currencies: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s account sensor", currency, - instance.api_version, ) if currency not in provided_currencies: _LOGGER.warning( @@ -89,9 +87,8 @@ async def async_setup_entry( if CONF_EXCHANGE_RATES in config_entry.options: for rate in config_entry.options[CONF_EXCHANGE_RATES]: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s exchange rate sensor", rate, - instance.api_version, ) entities.append( ExchangeRateSensor( @@ -146,15 +143,13 @@ class AccountSensor(SensorEntity): """Return the state attributes of the sensor.""" return { ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", - ATTR_API_VERSION: self._coinbase_data.api_version, } def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s account sensor with %s API", + "Updating %s account sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() for account in self._coinbase_data.accounts: @@ -210,9 +205,8 @@ class ExchangeRateSensor(SensorEntity): def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s rate sensor with %s API", + "Updating %s rate sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() self._attr_native_value = round( diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 74510731b7a..b0774baf403 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -8,6 +8,14 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "api_token": "API secret" } + }, + "reauth_confirm": { + "title": "Update Coinbase API credentials", + "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_token": "API secret" + } } }, "error": { @@ -18,7 +26,8 @@ "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": "Successfully updated credentials" } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index 1bd2631b36d..517e3f19bde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,9 +724,6 @@ clx-sdk-xms==1.0.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f2177be210..f63a164c3f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -633,9 +633,6 @@ caldav==1.6.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 0a2475ac218..be538c7a42d 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -5,7 +5,7 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from .const import ( @@ -65,7 +65,7 @@ class MockGetAccountsV3: start = ids.index(cursor) if cursor else 0 has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3) - end = target_end if has_next else -1 + end = target_end if has_next else len(MOCK_ACCOUNTS_RESPONSE_V3) next_cursor = ids[end] if has_next else ids[-1] self.accounts = { "accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end], @@ -120,31 +120,6 @@ async def init_mock_coinbase( hass: HomeAssistant, currencies: list[str] | None = None, rates: list[str] | None = None, -) -> MockConfigEntry: - """Init Coinbase integration for testing.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="080272b77a4f80c41b94d7cdc86fd826", - unique_id=None, - title="Test User", - data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, - options={ - CONF_CURRENCIES: currencies or [], - CONF_EXCHANGE_RATES: rates or [], - }, - ) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry - - -async def init_mock_coinbase_v3( - hass: HomeAssistant, - currencies: list[str] | None = None, - rates: list[str] | None = None, ) -> MockConfigEntry: """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( @@ -155,7 +130,6 @@ async def init_mock_coinbase_v3( data={ CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", }, options={ CONF_CURRENCIES: currencies or [], diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index aa2c6208e0f..0dc7fa95ffb 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -3,9 +3,8 @@ import logging from unittest.mock import patch -from coinbase.wallet.error import AuthenticationError +from coinbase.rest.rest_base import HTTPError import pytest -from requests.models import Response from homeassistant import config_entries from homeassistant.components.coinbase.const import ( @@ -14,17 +13,14 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .common import ( init_mock_coinbase, - init_mock_coinbase_v3, - mock_get_current_user, mock_get_exchange_rates, mock_get_portfolios, - mocked_get_accounts, mocked_get_accounts_v3, ) from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE @@ -41,13 +37,13 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), patch( "homeassistant.components.coinbase.async_setup_entry", @@ -61,11 +57,10 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test User" + assert result2["title"] == "Default" assert result2["data"] == { CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v2", } assert len(mock_setup_entry.mock_calls) == 1 @@ -80,16 +75,9 @@ async def test_form_invalid_auth( caplog.set_level(logging.DEBUG) - response = Response() - response.status_code = 401 - api_auth_error_unknown = AuthenticationError( - response, - "authentication_error", - "unknown error", - [{"id": "authentication_error", "message": "unknown error"}], - ) + api_auth_error_unknown = HTTPError("unknown error") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_unknown, ): result2 = await hass.config_entries.flow.async_configure( @@ -104,14 +92,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} assert "Coinbase rejected API credentials due to an unknown error" in caplog.text - api_auth_error_key = AuthenticationError( - response, - "authentication_error", - "invalid api key", - [{"id": "authentication_error", "message": "invalid api key"}], - ) + api_auth_error_key = HTTPError("invalid api key") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_key, ): result2 = await hass.config_entries.flow.async_configure( @@ -126,14 +109,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth_key"} assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text - api_auth_error_secret = AuthenticationError( - response, - "authentication_error", - "invalid signature", - [{"id": "authentication_error", "message": "invalid signature"}], - ) + api_auth_error_secret = HTTPError("invalid signature") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_secret, ): result2 = await hass.config_entries.flow.async_configure( @@ -158,7 +136,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=ConnectionError, ): result2 = await hass.config_entries.flow.async_configure( @@ -180,7 +158,7 @@ async def test_form_catch_all_exception(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -200,13 +178,13 @@ async def test_option_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), patch( "homeassistant.components.coinbase.update_listener" @@ -233,13 +211,13 @@ async def test_form_bad_account_currency(hass: HomeAssistant) -> None: """Test we handle a bad currency option.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -262,13 +240,13 @@ async def test_form_bad_exchange_rate(hass: HomeAssistant) -> None: """Test we handle a bad exchange rate.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -290,13 +268,13 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: """Test we handle an unknown exception in the option flow.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -304,7 +282,7 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: await hass.async_block_till_done() with patch( - "coinbase.wallet.client.Client.get_accounts", + "coinbase.rest.RESTClient.get_accounts", side_effect=Exception, ): result2 = await hass.config_entries.options.async_configure( @@ -320,75 +298,99 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_form_v3(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth flow.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), + ): + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Test successful reauth + with ( patch( - "homeassistant.components.coinbase.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF"}, + { + CONF_API_KEY: "new_key", + CONF_API_TOKEN: "new_secret", + }, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Default" - assert result2["data"] == { - CONF_API_KEY: "organizations/123456", - CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", - } - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "new_key" + assert config_entry.data[CONF_API_TOKEN] == "new_secret" -async def test_option_form_v3(hass: HomeAssistant) -> None: - """Test we handle a good wallet currency option.""" - +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth flow with invalid credentials.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), - patch( - "homeassistant.components.coinbase.update_listener" - ) as mock_update_listener, ): - config_entry = await init_mock_coinbase_v3(hass) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - result2 = await hass.config_entries.options.async_configure( + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + # Test invalid auth during reauth + api_auth_error_key = HTTPError("invalid api key") + with patch( + "coinbase.rest.RESTClient.get_portfolios", + side_effect=api_auth_error_key, + ): + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], - CONF_EXCHANGE_PRECISION: 5, + { + CONF_API_KEY: "bad_key", + CONF_API_TOKEN: "bad_secret", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "invalid_auth_key"} diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 98936f47e48..5e708756d80 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -27,13 +27,13 @@ async def test_entry_diagnostics( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 99b6bb4a9bd..7705a4d8e81 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -2,6 +2,9 @@ from unittest.mock import patch +import pytest + +from homeassistant.components.coinbase import create_and_update_instance from homeassistant.components.coinbase.const import ( API_TYPE_VAULT, CONF_CURRENCIES, @@ -9,14 +12,16 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from .const import ( GOOD_CURRENCY, @@ -30,16 +35,16 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), patch( - "coinbase.wallet.client.Client.get_accounts", - new=mocked_get_accounts, + "coinbase.rest.RESTClient.get_accounts", + new=mocked_get_accounts_v3, ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": {"rates": {}}}, ), ): entry = await init_mock_coinbase(hass) @@ -61,13 +66,13 @@ async def test_option_updates( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -141,13 +146,13 @@ async def test_ignore_vaults_wallets( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) @@ -159,3 +164,54 @@ async def test_ignore_vaults_wallets( assert len(entities) == 1 entity = entities[0] assert API_TYPE_VAULT not in entity.original_name.lower() + + +async def test_v2_api_credentials_trigger_reauth(hass: HomeAssistant) -> None: + """Test that v2 API credentials trigger a reauth flow.""" + + config_entry_data = { + CONF_API_KEY: "v2_api_key_legacy_format", + CONF_API_TOKEN: "v2_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + create_and_update_instance(entry) + + assert "deprecated v2 API" in str(exc_info.value) + + +async def test_v3_api_credentials_work(hass: HomeAssistant) -> None: + """Test that v3 API credentials with 'organizations' don't trigger reauth.""" + + config_entry_data = { + CONF_API_KEY: "organizations_v3_api_key", + CONF_API_TOKEN: "v3_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with ( + patch( + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), + ): + instance = create_and_update_instance(entry) + assert instance is not None From 3eda687d30d3de84a62b3926846c50d976f88c94 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Mon, 11 Aug 2025 17:08:07 +0200 Subject: [PATCH 1106/1118] Smarla integration sensor platform (#145748) --- homeassistant/components/smarla/const.py | 2 +- homeassistant/components/smarla/icons.json | 14 ++ homeassistant/components/smarla/sensor.py | 107 +++++++++ homeassistant/components/smarla/strings.json | 15 ++ tests/components/smarla/conftest.py | 12 + .../smarla/snapshots/test_sensor.ambr | 208 ++++++++++++++++++ tests/components/smarla/test_sensor.py | 85 +++++++ 7 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smarla/sensor.py create mode 100644 tests/components/smarla/snapshots/test_sensor.ambr create mode 100644 tests/components/smarla/test_sensor.py diff --git a/homeassistant/components/smarla/const.py b/homeassistant/components/smarla/const.py index f81ccd328bc..fcb64f1e315 100644 --- a/homeassistant/components/smarla/const.py +++ b/homeassistant/components/smarla/const.py @@ -6,7 +6,7 @@ DOMAIN = "smarla" HOST = "https://devices.swing2sleep.de" -PLATFORMS = [Platform.NUMBER, Platform.SWITCH] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] DEVICE_MODEL_NAME = "Smarla" MANUFACTURER_NAME = "Swing2Sleep" diff --git a/homeassistant/components/smarla/icons.json b/homeassistant/components/smarla/icons.json index 2ba7404cc35..a72e7e7ea12 100644 --- a/homeassistant/components/smarla/icons.json +++ b/homeassistant/components/smarla/icons.json @@ -9,6 +9,20 @@ "intensity": { "default": "mdi:sine-wave" } + }, + "sensor": { + "amplitude": { + "default": "mdi:sine-wave" + }, + "period": { + "default": "mdi:sine-wave" + }, + "activity": { + "default": "mdi:baby-face" + }, + "swing_count": { + "default": "mdi:counter" + } } } } diff --git a/homeassistant/components/smarla/sensor.py b/homeassistant/components/smarla/sensor.py new file mode 100644 index 00000000000..18bef76e320 --- /dev/null +++ b/homeassistant/components/smarla/sensor.py @@ -0,0 +1,107 @@ +"""Support for the Swing2Sleep Smarla sensor entities.""" + +from dataclasses import dataclass + +from pysmarlaapi.federwiege.classes import Property + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FederwiegeConfigEntry +from .entity import SmarlaBaseEntity, SmarlaEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription): + """Class describing Swing2Sleep Smarla sensor entities.""" + + multiple: bool = False + value_pos: int = 0 + + +SENSORS: list[SmarlaSensorEntityDescription] = [ + SmarlaSensorEntityDescription( + key="amplitude", + translation_key="amplitude", + service="analyser", + property="oscillation", + multiple=True, + value_pos=0, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="period", + translation_key="period", + service="analyser", + property="oscillation", + multiple=True, + value_pos=1, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="activity", + translation_key="activity", + service="analyser", + property="activity", + state_class=SensorStateClass.MEASUREMENT, + ), + SmarlaSensorEntityDescription( + key="swing_count", + translation_key="swing_count", + service="analyser", + property="swing_count", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FederwiegeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Smarla sensors from config entry.""" + federwiege = config_entry.runtime_data + async_add_entities( + ( + SmarlaSensor(federwiege, desc) + if not desc.multiple + else SmarlaSensorMultiple(federwiege, desc) + ) + for desc in SENSORS + ) + + +class SmarlaSensor(SmarlaBaseEntity, SensorEntity): + """Representation of Smarla sensor.""" + + entity_description: SmarlaSensorEntityDescription + + _property: Property[int] + + @property + def native_value(self) -> int | None: + """Return the entity value to represent the entity state.""" + return self._property.get() + + +class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity): + """Representation of Smarla sensor with multiple values inside property.""" + + entity_description: SmarlaSensorEntityDescription + + _property: Property[list[int]] + + @property + def native_value(self) -> int | None: + """Return the entity value to represent the entity state.""" + v = self._property.get() + return v[self.entity_description.value_pos] if v is not None else None diff --git a/homeassistant/components/smarla/strings.json b/homeassistant/components/smarla/strings.json index fbe5df4c1d0..edf306b1183 100644 --- a/homeassistant/components/smarla/strings.json +++ b/homeassistant/components/smarla/strings.json @@ -28,6 +28,21 @@ "intensity": { "name": "Intensity" } + }, + "sensor": { + "amplitude": { + "name": "Amplitude" + }, + "period": { + "name": "Period" + }, + "activity": { + "name": "Activity" + }, + "swing_count": { + "name": "Swing count", + "unit_of_measurement": "swings" + } } } } diff --git a/tests/components/smarla/conftest.py b/tests/components/smarla/conftest.py index d472e929bcc..d25dab2446f 100644 --- a/tests/components/smarla/conftest.py +++ b/tests/components/smarla/conftest.py @@ -73,8 +73,20 @@ def mock_federwiege(mock_connection: MagicMock) -> Generator[MagicMock]: mock_babywiege_service.props["smart_mode"].get.return_value = False mock_babywiege_service.props["intensity"].get.return_value = 1 + mock_analyser_service = MagicMock(spec=Service) + mock_analyser_service.props = { + "oscillation": MagicMock(spec=Property), + "activity": MagicMock(spec=Property), + "swing_count": MagicMock(spec=Property), + } + + mock_analyser_service.props["oscillation"].get.return_value = [0, 0] + mock_analyser_service.props["activity"].get.return_value = 0 + mock_analyser_service.props["swing_count"].get.return_value = 0 + federwiege.services = { "babywiege": mock_babywiege_service, + "analyser": mock_analyser_service, } federwiege.get_property = MagicMock( diff --git a/tests/components/smarla/snapshots/test_sensor.ambr b/tests/components/smarla/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..88d6a6ecea6 --- /dev/null +++ b/tests/components/smarla/snapshots/test_sensor.ambr @@ -0,0 +1,208 @@ +# serializer version: 1 +# name: test_entities[sensor.smarla_activity-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.smarla_activity', + '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': 'Activity', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'activity', + 'unique_id': 'ABCD-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.smarla_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Activity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.smarla_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_amplitude-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.smarla_amplitude', + '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': 'Amplitude', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'amplitude', + 'unique_id': 'ABCD-amplitude', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_amplitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Amplitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_amplitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_period-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.smarla_period', + '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': 'Period', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'period', + 'unique_id': 'ABCD-period', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smarla_period-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Period', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smarla_period', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-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.smarla_swing_count', + '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': 'Swing count', + 'platform': 'smarla', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'swing_count', + 'unique_id': 'ABCD-swing_count', + 'unit_of_measurement': 'swings', + }) +# --- +# name: test_entities[sensor.smarla_swing_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smarla Swing count', + 'state_class': , + 'unit_of_measurement': 'swings', + }), + 'context': , + 'entity_id': 'sensor.smarla_swing_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/smarla/test_sensor.py b/tests/components/smarla/test_sensor.py new file mode 100644 index 00000000000..196e6d2a6f0 --- /dev/null +++ b/tests/components/smarla/test_sensor.py @@ -0,0 +1,85 @@ +"""Test sensor platform for Swing2Sleep Smarla integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, update_property_listeners + +from tests.common import MockConfigEntry, snapshot_platform + +SENSOR_ENTITIES = [ + { + "entity_id": "sensor.smarla_amplitude", + "service": "analyser", + "property": "oscillation", + "test_value": [1, 0], + }, + { + "entity_id": "sensor.smarla_period", + "service": "analyser", + "property": "oscillation", + "test_value": [0, 1], + }, + { + "entity_id": "sensor.smarla_activity", + "service": "analyser", + "property": "activity", + "test_value": 1, + }, + { + "entity_id": "sensor.smarla_swing_count", + "service": "analyser", + "property": "swing_count", + "test_value": 1, + }, +] + + +@pytest.mark.usefixtures("mock_federwiege") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Smarla entities.""" + with ( + patch("homeassistant.components.smarla.PLATFORMS", [Platform.SENSOR]), + ): + assert await setup_integration(hass, mock_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize("entity_info", SENSOR_ENTITIES) +async def test_sensor_state_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_federwiege: MagicMock, + entity_info: dict[str, str], +) -> None: + """Test Smarla Sensor callback.""" + assert await setup_integration(hass, mock_config_entry) + + mock_sensor_property = mock_federwiege.get_property( + entity_info["service"], entity_info["property"] + ) + + entity_id = entity_info["entity_id"] + + assert hass.states.get(entity_id).state == "0" + + mock_sensor_property.get.return_value = entity_info["test_value"] + + await update_property_listeners(mock_sensor_property) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "1" From d02029143c4e53ecc9aa71ba308ef34d5b1310f2 Mon Sep 17 00:00:00 2001 From: CubeZ2mDeveloper Date: Tue, 12 Aug 2025 00:41:41 +0800 Subject: [PATCH 1107/1118] Add SONOFF Dongle Lite MG21 discovery support in ZHA (#148813) Co-authored-by: zetao.zheng <1050713479@qq.com> --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5cad3c823b8..e980d34402b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -106,6 +106,12 @@ "pid": "EA60", "description": "*sonoff*max*", "known_devices": ["SONOFF Dongle Max MG24"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*lite*mg21*", + "known_devices": ["sonoff zigbee dongle lite mg21"] } ], "zeroconf": [ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 18623926ce2..dee0367de24 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -143,6 +143,12 @@ USB = [ "pid": "EA60", "vid": "10C4", }, + { + "description": "*sonoff*lite*mg21*", + "domain": "zha", + "pid": "EA60", + "vid": "10C4", + }, { "domain": "zwave_js", "pid": "0200", From cb7c7767b5307d2e133a6c825f9e068b8e0cd9fb Mon Sep 17 00:00:00 2001 From: MB901 <80067777+MB901@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:46:57 +0200 Subject: [PATCH 1108/1118] Add model_id for Freebox integration (#150430) --- homeassistant/components/freebox/router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 8ba7d88d938..b2eb329b545 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -115,6 +115,7 @@ class FreeboxRouter: self._api: Freepybox = api self.name: str = freebox_config["model_info"]["pretty_name"] + self.model_id: str = freebox_config["model_info"]["name"] self.mac: str = freebox_config["mac"] self._sw_v: str = freebox_config["firmware_version"] self._hw_v: str | None = freebox_config.get("board_name") @@ -284,6 +285,7 @@ class FreeboxRouter: manufacturer="Freebox SAS", name=self.name, model=self.name, + model_id=self.model_id, sw_version=self._sw_v, hw_version=self._hw_v, ) From 1a9d1a96494d57f18717bb07e8e181353691967c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 11 Aug 2025 11:47:29 -0500 Subject: [PATCH 1109/1118] Handle non-streaming TTS case correctly (#150218) --- homeassistant/components/tts/__init__.py | 7 +- homeassistant/components/tts/entity.py | 12 +++ tests/components/tts/test_entity.py | 28 +++++++ tests/components/tts/test_init.py | 31 +++++++ .../wyoming/snapshots/test_tts.ambr | 80 ------------------- 5 files changed, 77 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index cf9099448df..629332d9d64 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -976,11 +976,15 @@ class SpeechManager: if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): + if isinstance(engine_instance, Provider) or ( + not engine_instance.async_supports_streaming_input() + ): + # Non-streaming if isinstance(message_or_stream, str): message = message_or_stream else: message = "".join([chunk async for chunk in message_or_stream]) + extension, data = await engine_instance.async_internal_get_tts_audio( message, language, options ) @@ -996,6 +1000,7 @@ class SpeechManager: data_gen = make_data_generator(data) else: + # Streaming if isinstance(message_or_stream, str): async def gen_stream() -> AsyncGenerator[str]: diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index aea5be6d0da..77abaa26bab 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -191,6 +191,18 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH """Load tts audio file from the engine.""" raise NotImplementedError + @final + async def async_internal_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine and update state. + + Return a tuple of file extension and data as bytes. + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio(message, language, options=options) + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py index 8648ca95e93..308d3bb0fca 100644 --- a/tests/components/tts/test_entity.py +++ b/tests/components/tts/test_entity.py @@ -175,3 +175,31 @@ def test_streaming_supported() -> None: sync_non_streaming_entity = SyncNonStreamingEntity() assert sync_non_streaming_entity.async_supports_streaming_input() is False + + +async def test_internal_get_tts_audio_writes_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test that only async_internal_get_tts_audio updates and writes the state.""" + + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + assert config_entry.state is ConfigEntryState.LOADED + state1 = hass.states.get(entity_id) + assert state1 is not None + + # State should *not* change with external method + await mock_tts_entity.async_get_tts_audio("test message", hass.config.language, {}) + state2 = hass.states.get(entity_id) + assert state2 is not None + assert state1.state == state2.state + + # State *should* change with internal method + await mock_tts_entity.async_internal_get_tts_audio( + "test message", hass.config.language, {} + ) + state3 = hass.states.get(entity_id) + assert state3 is not None + assert state1.state != state3.state diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index db42da5de0e..be155aae182 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2032,3 +2032,34 @@ async def test_tts_cache() -> None: assert await consume_mid_data_task == b"012" with pytest.raises(ValueError): assert await consume_pre_data_loaded_task == b"012" + + +async def test_async_internal_get_tts_audio_called( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, + hass_client: ClientSessionGenerator, +) -> None: + """Test that non-streaming entity has its async_internal_get_tts_audio method called.""" + + await mock_config_entry_setup(hass, mock_tts_entity) + + # Non-streaming + assert mock_tts_entity.async_supports_streaming_input() is False + + with patch( + "homeassistant.components.tts.entity.TextToSpeechEntity.async_internal_get_tts_audio" + ) as internal_get_tts_audio: + media_source_id = tts.generate_media_source_id( + hass, + "test message", + "tts.test", + "en_US", + cache=None, + ) + + url = await get_media_source_url(hass, media_source_id) + client = await hass_client() + await client.get(url) + + # async_internal_get_tts_audio is called + internal_get_tts_audio.assert_called_once_with("test message", "en_US", {}) diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 67c9b24160c..53cc02eaacf 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -1,19 +1,6 @@ # serializer version: 1 # name: test_get_tts_audio list([ - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-start', - }), - dict({ - 'data': dict({ - 'text': 'Hello world', - }), - 'payload': None, - 'type': 'synthesize-chunk', - }), dict({ 'data': dict({ 'text': 'Hello world', @@ -21,29 +8,10 @@ 'payload': None, 'type': 'synthesize', }), - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-stop', - }), ]) # --- # name: test_get_tts_audio_different_formats list([ - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-start', - }), - dict({ - 'data': dict({ - 'text': 'Hello world', - }), - 'payload': None, - 'type': 'synthesize-chunk', - }), dict({ 'data': dict({ 'text': 'Hello world', @@ -51,29 +19,10 @@ 'payload': None, 'type': 'synthesize', }), - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-stop', - }), ]) # --- # name: test_get_tts_audio_different_formats.1 list([ - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-start', - }), - dict({ - 'data': dict({ - 'text': 'Hello world', - }), - 'payload': None, - 'type': 'synthesize-chunk', - }), dict({ 'data': dict({ 'text': 'Hello world', @@ -81,12 +30,6 @@ 'payload': None, 'type': 'synthesize', }), - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-stop', - }), ]) # --- # name: test_get_tts_audio_streaming @@ -128,23 +71,6 @@ # --- # name: test_voice_speaker list([ - dict({ - 'data': dict({ - 'voice': dict({ - 'name': 'voice1', - 'speaker': 'speaker1', - }), - }), - 'payload': None, - 'type': 'synthesize-start', - }), - dict({ - 'data': dict({ - 'text': 'Hello world', - }), - 'payload': None, - 'type': 'synthesize-chunk', - }), dict({ 'data': dict({ 'text': 'Hello world', @@ -156,11 +82,5 @@ 'payload': None, 'type': 'synthesize', }), - dict({ - 'data': dict({ - }), - 'payload': None, - 'type': 'synthesize-stop', - }), ]) # --- From 91f6b8e1fe500f818434195294ac4ccf41c36335 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:03:37 +0200 Subject: [PATCH 1110/1118] Add Sleep as Android integration (#142569) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/sleep_as_android/__init__.py | 66 +++ .../sleep_as_android/config_flow.py | 14 + .../components/sleep_as_android/const.py | 30 ++ .../components/sleep_as_android/entity.py | 33 ++ .../components/sleep_as_android/event.py | 161 ++++++ .../components/sleep_as_android/icons.json | 30 ++ .../components/sleep_as_android/manifest.json | 10 + .../sleep_as_android/quality_scale.yaml | 110 ++++ .../components/sleep_as_android/strings.json | 123 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + tests/components/sleep_as_android/__init__.py | 1 + tests/components/sleep_as_android/conftest.py | 34 ++ .../snapshots/test_event.ambr | 494 ++++++++++++++++++ .../sleep_as_android/test_config_flow.py | 38 ++ .../components/sleep_as_android/test_event.py | 161 ++++++ .../components/sleep_as_android/test_init.py | 22 + 20 files changed, 1347 insertions(+) create mode 100644 homeassistant/components/sleep_as_android/__init__.py create mode 100644 homeassistant/components/sleep_as_android/config_flow.py create mode 100644 homeassistant/components/sleep_as_android/const.py create mode 100644 homeassistant/components/sleep_as_android/entity.py create mode 100644 homeassistant/components/sleep_as_android/event.py create mode 100644 homeassistant/components/sleep_as_android/icons.json create mode 100644 homeassistant/components/sleep_as_android/manifest.json create mode 100644 homeassistant/components/sleep_as_android/quality_scale.yaml create mode 100644 homeassistant/components/sleep_as_android/strings.json create mode 100644 tests/components/sleep_as_android/__init__.py create mode 100644 tests/components/sleep_as_android/conftest.py create mode 100644 tests/components/sleep_as_android/snapshots/test_event.ambr create mode 100644 tests/components/sleep_as_android/test_config_flow.py create mode 100644 tests/components/sleep_as_android/test_event.py create mode 100644 tests/components/sleep_as_android/test_init.py diff --git a/.strict-typing b/.strict-typing index 98973c89a5a..b3e41747239 100644 --- a/.strict-typing +++ b/.strict-typing @@ -466,6 +466,7 @@ homeassistant.components.simplisafe.* homeassistant.components.siren.* homeassistant.components.skybell.* homeassistant.components.slack.* +homeassistant.components.sleep_as_android.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.smlight.* diff --git a/CODEOWNERS b/CODEOWNERS index 9a7b961748c..b9a8367ba3e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1415,6 +1415,8 @@ build.json @home-assistant/supervisor /tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @tkdrob @fletcherau /tests/components/slack/ @tkdrob @fletcherau +/homeassistant/components/sleep_as_android/ @tr4nt0r +/tests/components/sleep_as_android/ @tr4nt0r /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/sleep_as_android/__init__.py b/homeassistant/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..09a77504e12 --- /dev/null +++ b/homeassistant/components/sleep_as_android/__init__.py @@ -0,0 +1,66 @@ +"""The Sleep as Android integration.""" + +from __future__ import annotations + +from http import HTTPStatus + +from aiohttp.web import Request, Response +import voluptuous as vol + +from homeassistant.components import webhook +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ATTR_EVENT, ATTR_VALUE1, ATTR_VALUE2, ATTR_VALUE3, DOMAIN + +PLATFORMS: list[Platform] = [Platform.EVENT] + +type SleepAsAndroidConfigEntry = ConfigEntry + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_EVENT): str, + vol.Optional(ATTR_VALUE1): str, + vol.Optional(ATTR_VALUE2): str, + vol.Optional(ATTR_VALUE3): str, + } +) + + +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> Response: + """Handle incoming Sleep as Android webhook request.""" + + try: + data = WEBHOOK_SCHEMA(await request.json()) + except vol.MultipleInvalid as error: + return Response( + text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + ) + + async_dispatcher_send(hass, DOMAIN, webhook_id, data) + return Response(status=HTTPStatus.NO_CONTENT) + + +async def async_setup_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Set up Sleep as Android from a config entry.""" + + webhook.async_register( + hass, DOMAIN, entry.title, entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SleepAsAndroidConfigEntry +) -> bool: + """Unload a config entry.""" + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sleep_as_android/config_flow.py b/homeassistant/components/sleep_as_android/config_flow.py new file mode 100644 index 00000000000..595612cc601 --- /dev/null +++ b/homeassistant/components/sleep_as_android/config_flow.py @@ -0,0 +1,14 @@ +"""Config flow for the Sleep as Android integration.""" + +from __future__ import annotations + +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Sleep as Android", + {"docs_url": "https://www.home-assistant.io/integrations/sleep_as_android"}, + allow_multiple=True, +) diff --git a/homeassistant/components/sleep_as_android/const.py b/homeassistant/components/sleep_as_android/const.py new file mode 100644 index 00000000000..057c326aa86 --- /dev/null +++ b/homeassistant/components/sleep_as_android/const.py @@ -0,0 +1,30 @@ +"""Constants for the Sleep as Android integration.""" + +DOMAIN = "sleep_as_android" + +ATTR_EVENT = "event" +ATTR_VALUE1 = "value1" +ATTR_VALUE2 = "value2" +ATTR_VALUE3 = "value3" + +MAP_EVENTS = { + "sleep_tracking_paused": "paused", + "sleep_tracking_resumed": "resumed", + "sleep_tracking_started": "started", + "sleep_tracking_stopped": "stopped", + "alarm_alert_dismiss": "alert_dismiss", + "alarm_alert_start": "alert_start", + "alarm_rescheduled": "rescheduled", + "alarm_skip_next": "skip_next", + "alarm_snooze_canceled": "snooze_canceled", + "alarm_snooze_clicked": "snooze_clicked", + "alarm_wake_up_check": "wake_up_check", + "sound_event_baby": "baby", + "sound_event_cough": "cough", + "sound_event_laugh": "laugh", + "sound_event_snore": "snore", + "sound_event_talk": "talk", + "lullaby_start": "start", + "lullaby_stop": "stop", + "lullaby_volume_down": "volume_down", +} diff --git a/homeassistant/components/sleep_as_android/entity.py b/homeassistant/components/sleep_as_android/entity.py new file mode 100644 index 00000000000..5a4008d0cdd --- /dev/null +++ b/homeassistant/components/sleep_as_android/entity.py @@ -0,0 +1,33 @@ +"""Base entity for Sleep as Android integration.""" + +from __future__ import annotations + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from . import SleepAsAndroidConfigEntry +from .const import DOMAIN + + +class SleepAsAndroidEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: SleepAsAndroidConfigEntry, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + + self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" + self.entity_description = entity_description + self.webhook_id = config_entry.data[CONF_WEBHOOK_ID] + self._attr_device_info = DeviceInfo( + connections={(DOMAIN, config_entry.entry_id)}, + manufacturer="Urbandroid", + model="Sleep as Android", + name=config_entry.title, + ) diff --git a/homeassistant/components/sleep_as_android/event.py b/homeassistant/components/sleep_as_android/event.py new file mode 100644 index 00000000000..189accd7601 --- /dev/null +++ b/homeassistant/components/sleep_as_android/event.py @@ -0,0 +1,161 @@ +"""Event platform for Sleep as Android integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SleepAsAndroidConfigEntry +from .const import ATTR_EVENT, DOMAIN, MAP_EVENTS +from .entity import SleepAsAndroidEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class SleepAsAndroidEventEntityDescription(EventEntityDescription): + """Sleep as Android sensor description.""" + + event_types: list[str] + + +class SleepAsAndroidEvent(StrEnum): + """Sleep as Android events.""" + + ALARM_CLOCK = "alarm_clock" + USER_NOTIFICATION = "user_notification" + SMART_WAKEUP = "smart_wakeup" + SLEEP_HEALTH = "sleep_health" + LULLABY = "lullaby" + SLEEP_PHASE = "sleep_phase" + SLEEP_TRACKING = "sleep_tracking" + SOUND_EVENT = "sound_event" + + +EVENT_DESCRIPTIONS: tuple[SleepAsAndroidEventEntityDescription, ...] = ( + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_TRACKING, + translation_key=SleepAsAndroidEvent.SLEEP_TRACKING, + device_class=EventDeviceClass.BUTTON, + event_types=[ + "paused", + "resumed", + "started", + "stopped", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.ALARM_CLOCK, + translation_key=SleepAsAndroidEvent.ALARM_CLOCK, + event_types=[ + "alert_dismiss", + "alert_start", + "rescheduled", + "skip_next", + "snooze_canceled", + "snooze_clicked", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SMART_WAKEUP, + translation_key=SleepAsAndroidEvent.SMART_WAKEUP, + event_types=[ + "before_smart_period", + "smart_period", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.USER_NOTIFICATION, + translation_key=SleepAsAndroidEvent.USER_NOTIFICATION, + event_types=[ + "wake_up_check", + "show_skip_next_alarm", + "time_to_bed_alarm_alert", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_PHASE, + translation_key=SleepAsAndroidEvent.SLEEP_PHASE, + event_types=[ + "awake", + "deep_sleep", + "light_sleep", + "not_awake", + "rem", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SOUND_EVENT, + translation_key=SleepAsAndroidEvent.SOUND_EVENT, + event_types=[ + "baby", + "cough", + "laugh", + "snore", + "talk", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.LULLABY, + translation_key=SleepAsAndroidEvent.LULLABY, + event_types=[ + "start", + "stop", + "volume_down", + ], + ), + SleepAsAndroidEventEntityDescription( + key=SleepAsAndroidEvent.SLEEP_HEALTH, + translation_key=SleepAsAndroidEvent.SLEEP_HEALTH, + event_types=[ + "antisnoring", + "apnea_alarm", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SleepAsAndroidConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event platform.""" + + async_add_entities( + SleepAsAndroidEventEntity(config_entry, description) + for description in EVENT_DESCRIPTIONS + ) + + +class SleepAsAndroidEventEntity(SleepAsAndroidEntity, EventEntity): + """An event entity.""" + + entity_description: SleepAsAndroidEventEntityDescription + + @callback + def _async_handle_event(self, webhook_id: str, data: dict[str, str]) -> None: + """Handle the Sleep as Android event.""" + event = MAP_EVENTS.get(data[ATTR_EVENT], data[ATTR_EVENT]) + if ( + webhook_id == self.webhook_id + and event in self.entity_description.event_types + ): + self._trigger_event(event) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register event callback.""" + + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event) + ) diff --git a/homeassistant/components/sleep_as_android/icons.json b/homeassistant/components/sleep_as_android/icons.json new file mode 100644 index 00000000000..001cb5ec561 --- /dev/null +++ b/homeassistant/components/sleep_as_android/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "event": { + "alarm_clock": { + "default": "mdi:alarm" + }, + "user_notification": { + "default": "mdi:cellphone-message" + }, + "smart_wakeup": { + "default": "mdi:brain" + }, + "sleep_phase": { + "default": "mdi:bed" + }, + "sound_event": { + "default": "mdi:chat-sleep-outline" + }, + "sleep_tracking": { + "default": "mdi:record-rec" + }, + "lullaby": { + "default": "mdi:cradle-outline" + }, + "sleep_health": { + "default": "mdi:heart-pulse" + } + } + } +} diff --git a/homeassistant/components/sleep_as_android/manifest.json b/homeassistant/components/sleep_as_android/manifest.json new file mode 100644 index 00000000000..fbac134ffa1 --- /dev/null +++ b/homeassistant/components/sleep_as_android/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sleep_as_android", + "name": "Sleep as Android", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/sleep_as_android", + "iot_class": "local_push", + "quality_scale": "silver" +} diff --git a/homeassistant/components/sleep_as_android/quality_scale.yaml b/homeassistant/components/sleep_as_android/quality_scale.yaml new file mode 100644 index 00000000000..5565f9eb834 --- /dev/null +++ b/homeassistant/components/sleep_as_android/quality_scale.yaml @@ -0,0 +1,110 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: has no actions + appropriate-polling: + status: exempt + comment: does not poll + brands: done + common-modules: done + config-flow-test-coverage: + status: done + comment: uses webhook flow helper, already covered + config-flow: done + dependency-transparency: + status: exempt + comment: no dependencies + docs-actions: + status: exempt + comment: no actions + 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: + status: exempt + comment: has no runtime data + test-before-configure: + status: exempt + comment: nothing to test + test-before-setup: + status: exempt + comment: nothing to test + unique-config-entry: + status: exempt + comment: only 1 webhook can be configured per device. It's not possible to prevent different devices from using the same webhook + + # Silver + action-exceptions: + status: exempt + comment: no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: only state-less entities + integration-owner: done + log-when-unavailable: + status: exempt + comment: only state-less entities + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication required + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: no discovery + discovery: + status: exempt + comment: cannot be discovered + docs-data-update: + status: exempt + comment: does not poll + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: has no devices + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: has no devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: + status: exempt + comment: does not raise exceptions + icon-translations: done + reconfiguration-flow: + status: exempt + comment: webhook config flow helper does not implement reconfigure + repair-issues: + status: exempt + comment: has no repairs + stale-devices: + status: exempt + comment: has no stale devices + + # Platinum + async-dependency: + status: exempt + comment: has no external dependencies + inject-websession: + status: exempt + comment: does not do http requests + strict-typing: done diff --git a/homeassistant/components/sleep_as_android/strings.json b/homeassistant/components/sleep_as_android/strings.json new file mode 100644 index 00000000000..2822961c18e --- /dev/null +++ b/homeassistant/components/sleep_as_android/strings.json @@ -0,0 +1,123 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up Sleep as Android", + "description": "Are you sure you want to set up the Sleep as Android integration?" + } + }, + "abort": { + "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", + "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" + }, + "create_entry": { + "default": "To send events to Home Assistant, you will need to set up a webhook.\n\nOpen Sleep as Android and go to *Settings → Services → Automation → Webhooks*\n\nEnable *Webhooks* and fill in the following webhook in the URL field:\n\n`{webhook_url}`\n\nSee [the documentation]({docs_url}) for further details." + } + }, + "entity": { + "event": { + "sleep_tracking": { + "name": "Sleep tracking", + "state_attributes": { + "event_type": { + "state": { + "paused": "[%key:common::state::paused%]", + "resumed": "Resumed", + "started": "Started", + "stopped": "[%key:common::state::stopped%]" + } + } + } + }, + "alarm_clock": { + "name": "Alarm clock", + "state_attributes": { + "event_type": { + "state": { + "alert_dismiss": "Alarm dismissed", + "alert_start": "Alarm started", + "rescheduled": "Alarm rescheduled", + "skip_next": "Alarm skipped", + "snooze_canceled": "Snooze canceled", + "snooze_clicked": "Snoozing" + } + } + } + }, + "smart_wakeup": { + "name": "Smart wake-up", + "state_attributes": { + "event_type": { + "state": { + "before_smart_period": "45min before smart wake-up", + "smart_period": "Smart wake-up started" + } + } + } + }, + "user_notification": { + "name": "User notification", + "state_attributes": { + "event_type": { + "state": { + "wake_up_check": "Wake-up check", + "show_skip_next_alarm": "Skip next alarm", + "time_to_bed_alarm_alert": "Time to bed" + } + } + } + }, + "sleep_phase": { + "name": "Sleep phase", + "state_attributes": { + "event_type": { + "state": { + "awake": "Woke up", + "deep_sleep": "Deep sleep", + "light_sleep": "Light sleep", + "not_awake": "Fell asleep", + "rem": "REM sleep" + } + } + } + }, + "sound_event": { + "name": "Sound recognition", + "state_attributes": { + "event_type": { + "state": { + "baby": "Baby crying", + "cough": "Coughing or sneezing", + "laugh": "Laughter", + "snore": "Snoring", + "talk": "Talking" + } + } + } + }, + "lullaby": { + "name": "Lullaby", + "state_attributes": { + "event_type": { + "state": { + "start": "Started", + "stop": "[%key:common::state::stopped%]", + "volume_down": "Lowering volume" + } + } + } + }, + "sleep_health": { + "name": "Sleep health", + "state_attributes": { + "event_type": { + "state": { + "antisnoring": "Anti-snoring triggered", + "apnea_alarm": "Sleep apnea detected" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 823bd339d51..19fb5491465 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -576,6 +576,7 @@ FLOWS = { "sky_remote", "skybell", "slack", + "sleep_as_android", "sleepiq", "slide_local", "slimproto", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c656958b3cc..10f5ea45427 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5994,6 +5994,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "sleep_as_android": { + "name": "Sleep as Android", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "sleepiq": { "name": "SleepIQ", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 91c75beb64a..ad9196c80c5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4416,6 +4416,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sleep_as_android.*] +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.sleepiq.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/sleep_as_android/__init__.py b/tests/components/sleep_as_android/__init__.py new file mode 100644 index 00000000000..3b970b011e7 --- /dev/null +++ b/tests/components/sleep_as_android/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sleep as Android integration.""" diff --git a/tests/components/sleep_as_android/conftest.py b/tests/components/sleep_as_android/conftest.py new file mode 100644 index 00000000000..97cc6da16a0 --- /dev/null +++ b/tests/components/sleep_as_android/conftest.py @@ -0,0 +1,34 @@ +"""Common fixtures for the Sleep as Android tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.const import CONF_WEBHOOK_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sleep_as_android.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Sleep as Android configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sleep as Android", + data={ + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + }, + entry_id="01JRD840SAZ55DGXBD78PTQ4EF", + ) diff --git a/tests/components/sleep_as_android/snapshots/test_event.ambr b/tests/components/sleep_as_android/snapshots/test_event.ambr new file mode 100644 index 00000000000..27e789351a3 --- /dev/null +++ b/tests/components/sleep_as_android/snapshots/test_event.ambr @@ -0,0 +1,494 @@ +# serializer version: 1 +# name: test_setup[event.sleep_as_android_alarm_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_alarm_clock', + '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': 'Alarm clock', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_alarm_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_alarm_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'alert_dismiss', + 'alert_start', + 'rescheduled', + 'skip_next', + 'snooze_canceled', + 'snooze_clicked', + ]), + 'friendly_name': 'Sleep as Android Alarm clock', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_alarm_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_lullaby', + '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': 'Lullaby', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_lullaby', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_lullaby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'start', + 'stop', + 'volume_down', + ]), + 'friendly_name': 'Sleep as Android Lullaby', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_lullaby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_health', + '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': 'Sleep health', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_health', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_health-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'antisnoring', + 'apnea_alarm', + ]), + 'friendly_name': 'Sleep as Android Sleep health', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_health', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_phase', + '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': 'Sleep phase', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'awake', + 'deep_sleep', + 'light_sleep', + 'not_awake', + 'rem', + ]), + 'friendly_name': 'Sleep as Android Sleep phase', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep tracking', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sleep_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sleep_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'paused', + 'resumed', + 'started', + 'stopped', + ]), + 'friendly_name': 'Sleep as Android Sleep tracking', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sleep_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_smart_wake_up', + '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': 'Smart wake-up', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_smart_wakeup', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_smart_wake_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'before_smart_period', + 'smart_period', + ]), + 'friendly_name': 'Sleep as Android Smart wake-up', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_smart_wake_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_sound_recognition', + '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': 'Sound recognition', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_sound_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_sound_recognition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'baby', + 'cough', + 'laugh', + 'snore', + 'talk', + ]), + 'friendly_name': 'Sleep as Android Sound recognition', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_sound_recognition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sleep_as_android_user_notification', + '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': 'User notification', + 'platform': 'sleep_as_android', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01JRD840SAZ55DGXBD78PTQ4EF_user_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.sleep_as_android_user_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'wake_up_check', + 'show_skip_next_alarm', + 'time_to_bed_alarm_alert', + ]), + 'friendly_name': 'Sleep as Android User notification', + }), + 'context': , + 'entity_id': 'event.sleep_as_android_user_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sleep_as_android/test_config_flow.py b/tests/components/sleep_as_android/test_config_flow.py new file mode 100644 index 00000000000..1642263d0ed --- /dev/null +++ b/tests/components/sleep_as_android/test_config_flow.py @@ -0,0 +1,38 @@ +"""Test the Sleep as Android config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.sleep_as_android.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.webhook.async_generate_id", + return_value="webhook_id", + ), + patch( + "homeassistant.components.webhook.async_generate_url", + return_value="http://example.com:8123", + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sleep as Android" + assert result["data"] == { + "cloudhook": False, + CONF_WEBHOOK_ID: "webhook_id", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sleep_as_android/test_event.py b/tests/components/sleep_as_android/test_event.py new file mode 100644 index 00000000000..514b3566dd7 --- /dev/null +++ b/tests/components/sleep_as_android/test_event.py @@ -0,0 +1,161 @@ +"""Test the Sleep as Android event platform.""" + +from http import HTTPStatus + +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity", "payload"), + [ + ("sleep_tracking", {"event": "sleep_tracking_paused"}), + ("sleep_tracking", {"event": "sleep_tracking_resumed"}), + ("sleep_tracking", {"event": "sleep_tracking_started"}), + ("sleep_tracking", {"event": "sleep_tracking_stopped"}), + ( + "alarm_clock", + { + "event": "alarm_alert_dismiss", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_alert_start", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("alarm_clock", {"event": "alarm_rescheduled"}), + ( + "alarm_clock", + {"event": "alarm_skip_next", "value1": "1582719660934", "value2": "label"}, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_canceled", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "alarm_clock", + { + "event": "alarm_snooze_clicked", + "value1": "1582719660934", + "value2": "label", + }, + ), + ("smart_wake_up", {"event": "before_smart_period", "value1": "label"}), + ("smart_wake_up", {"event": "smart_period"}), + ("sleep_health", {"event": "antisnoring"}), + ("sleep_health", {"event": "apnea_alarm"}), + ("lullaby", {"event": "lullaby_start"}), + ("lullaby", {"event": "lullaby_stop"}), + ("lullaby", {"event": "lullaby_volume_down"}), + ("sleep_phase", {"event": "awake"}), + ("sleep_phase", {"event": "deep_sleep"}), + ("sleep_phase", {"event": "light_sleep"}), + ("sleep_phase", {"event": "not_awake"}), + ("sleep_phase", {"event": "rem"}), + ("sound_recognition", {"event": "sound_event_baby"}), + ("sound_recognition", {"event": "sound_event_cough"}), + ("sound_recognition", {"event": "sound_event_laugh"}), + ("sound_recognition", {"event": "sound_event_snore"}), + ("sound_recognition", {"event": "sound_event_talk"}), + ("user_notification", {"event": "alarm_wake_up_check"}), + ( + "user_notification", + { + "event": "show_skip_next_alarm", + "value1": "1582719660934", + "value2": "label", + }, + ), + ( + "user_notification", + { + "event": "time_to_bed_alarm_alert", + "value1": "1582719660934", + "value2": "label", + }, + ), + ], +) +@freeze_time("2025-01-01T03:30:00.000+00:00") +async def test_webhook_event( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + entity: str, + payload: dict[str, str], +) -> None: + """Test webhook events.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get(f"event.sleep_as_android_{entity}")) + assert state.state == STATE_UNKNOWN + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json=payload) + assert response.status == HTTPStatus.NO_CONTENT + + assert (state := hass.states.get(f"event.sleep_as_android_{entity}")) + assert state.state == "2025-01-01T03:30:00.000+00:00" + + +async def test_webhook_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test webhook event call with invalid data.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + client = await hass_client_no_auth() + + response = await client.post("/api/webhook/webhook_id", json={}) + + assert response.status == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/tests/components/sleep_as_android/test_init.py b/tests/components/sleep_as_android/test_init.py new file mode 100644 index 00000000000..27177a5a5ad --- /dev/null +++ b/tests/components/sleep_as_android/test_init.py @@ -0,0 +1,22 @@ +"""Test the Sleep as Android integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED From 065a53a90d142716aded4a94ad0aaae74f6de299 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:27:33 -0700 Subject: [PATCH 1111/1118] Add quality scale and set Platinum for NUT (#143269) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/manifest.json | 1 + .../components/nut/quality_scale.yaml | 90 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/nut/quality_scale.yaml diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index 1ee85a84caf..608f2c2e495 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -7,6 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["aionut"], + "quality_scale": "platinum", "requirements": ["aionut==4.3.4"], "zeroconf": ["_nut._tcp.local."] } diff --git a/homeassistant/components/nut/quality_scale.yaml b/homeassistant/components/nut/quality_scale.yaml new file mode 100644 index 00000000000..823b1091ef6 --- /dev/null +++ b/homeassistant/components/nut/quality_scale.yaml @@ -0,0 +1,90 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom service actions are registered + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom service actions are registered + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No custom event subscriptions are available + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom service actions are registered + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No configuration parameters are available + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + The NUT server has no unique id for reliably determining updates + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + No repairable issues are raised + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration uses NUT protocol and does not communicate via HTTP/HTTPS + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 0050cddc708..318b9d3f7cb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -712,7 +712,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nuheat", "nuki", "numato", - "nut", "nws", "nx584", "nzbget", @@ -1758,7 +1757,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "nuheat", "nuki", "numato", - "nut", "nws", "nx584", "nzbget", From 9e398ffc10ded9b2c458b9351460ce834af31d5a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 11 Aug 2025 23:05:44 +0300 Subject: [PATCH 1112/1118] Bump to ruuvitag-ble==0.2.1 (#150436) --- homeassistant/components/ruuvitag_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/ruuvitag_ble/manifest.json b/homeassistant/components/ruuvitag_ble/manifest.json index fa8ec80423c..1051c9613a6 100644 --- a/homeassistant/components/ruuvitag_ble/manifest.json +++ b/homeassistant/components/ruuvitag_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble", "iot_class": "local_push", - "requirements": ["ruuvitag-ble==0.1.2"] + "requirements": ["ruuvitag-ble==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 517e3f19bde..12d143f452b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2702,7 +2702,7 @@ rpi-bad-power==0.1.0 russound==0.2.0 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.2 +ruuvitag-ble==0.2.1 # homeassistant.components.yamaha rxv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f63a164c3f6..fe5a7412bcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2233,7 +2233,7 @@ rova==0.4.1 rpi-bad-power==0.1.0 # homeassistant.components.ruuvitag_ble -ruuvitag-ble==0.1.2 +ruuvitag-ble==0.2.1 # homeassistant.components.yamaha rxv==0.7.0 From e394435d7c5b3427d9763cd6b5e37945e6aaaed9 Mon Sep 17 00:00:00 2001 From: Foscam-wangzhengyu Date: Tue, 12 Aug 2025 04:14:32 +0800 Subject: [PATCH 1113/1118] Add more Foscam switches (#147409) Co-authored-by: Joostlek --- homeassistant/components/foscam/__init__.py | 3 +- homeassistant/components/foscam/const.py | 13 + .../components/foscam/coordinator.py | 128 +++++- homeassistant/components/foscam/entity.py | 10 +- homeassistant/components/foscam/icons.json | 34 ++ homeassistant/components/foscam/strings.json | 29 +- homeassistant/components/foscam/switch.py | 187 +++++++-- tests/components/foscam/conftest.py | 15 + .../foscam/snapshots/test_switch.ambr | 385 ++++++++++++++++++ tests/components/foscam/test_init.py | 2 +- tests/components/foscam/test_switch.py | 35 ++ 11 files changed, 763 insertions(+), 78 deletions(-) create mode 100644 tests/components/foscam/snapshots/test_switch.ambr create mode 100644 tests/components/foscam/test_switch.py diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 222a7e44a45..099123ccd9b 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -30,7 +30,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo verbose=False, ) coordinator = FoscamCoordinator(hass, entry, session) - await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -89,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> async def async_migrate_entities(hass: HomeAssistant, entry: FoscamConfigEntry) -> None: - """Migrate old entry.""" + """Migrate old entries to support config_entry_id-based unique IDs.""" @callback def _update_unique_id( diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py index 38088cf3f6f..33c1b31aeec 100644 --- a/homeassistant/components/foscam/const.py +++ b/homeassistant/components/foscam/const.py @@ -11,3 +11,16 @@ CONF_STREAM = "stream" SERVICE_PTZ = "ptz" SERVICE_PTZ_PRESET = "ptz_preset" + +SUPPORTED_SWITCHES = [ + "flip_switch", + "mirror_switch", + "ir_switch", + "sleep_switch", + "white_light_switch", + "siren_alarm_switch", + "turn_off_volume_switch", + "light_status_switch", + "hdr_switch", + "wdr_switch", +] diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 72bf60cffe0..50ddd76ddb3 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -1,8 +1,8 @@ """The foscam coordinator object.""" import asyncio +from dataclasses import dataclass from datetime import timedelta -from typing import Any from libpyfoscamcgi import FoscamCamera @@ -15,9 +15,35 @@ from .const import DOMAIN, LOGGER type FoscamConfigEntry = ConfigEntry[FoscamCoordinator] -class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): +@dataclass +class FoscamDeviceInfo: + """A data class representing the current state and configuration of a Foscam camera device.""" + + dev_info: dict + product_info: dict + + is_open_ir: bool + is_flip: bool + is_mirror: bool + + is_asleep: dict + is_open_white_light: bool + is_siren_alarm: bool + + volume: int + speak_volume: int + is_turn_off_volume: bool + is_turn_off_light: bool + + is_open_wdr: bool | None = None + is_open_hdr: bool | None = None + + +class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): """Foscam coordinator.""" + config_entry: FoscamConfigEntry + def __init__( self, hass: HomeAssistant, @@ -34,24 +60,82 @@ class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.session = session - async def _async_update_data(self) -> dict[str, Any]: + def gather_all_configs(self) -> FoscamDeviceInfo: + """Get all Foscam configurations.""" + ret_dev_info, dev_info = self.session.get_dev_info() + dev_info = dev_info if ret_dev_info == 0 else {} + + ret_product_info, product_info = self.session.get_product_all_info() + product_info = product_info if ret_product_info == 0 else {} + + ret_ir, infra_led_config = self.session.get_infra_led_config() + is_open_ir = infra_led_config["mode"] == "1" if ret_ir == 0 else False + + ret_mf, mirror_flip_setting = self.session.get_mirror_and_flip_setting() + is_flip = mirror_flip_setting["isFlip"] == "1" if ret_mf == 0 else False + is_mirror = mirror_flip_setting["isMirror"] == "1" if ret_mf == 0 else False + + ret_sleep, sleep_setting = self.session.is_asleep() + is_asleep = {"supported": ret_sleep == 0, "status": bool(int(sleep_setting))} + + ret_wl, is_open_white_light = self.session.getWhiteLightBrightness() + is_open_white_light_val = ( + is_open_white_light["enable"] == "1" if ret_wl == 0 else False + ) + + ret_sc, is_siren_alarm = self.session.getSirenConfig() + is_siren_alarm_val = ( + is_siren_alarm["sirenEnable"] == "1" if ret_sc == 0 else False + ) + + ret_vol, volume = self.session.getAudioVolume() + volume_val = int(volume["volume"]) if ret_vol == 0 else 0 + + ret_sv, speak_volume = self.session.getSpeakVolume() + speak_volume_val = int(speak_volume["SpeakVolume"]) if ret_sv == 0 else 0 + + ret_ves, is_turn_off_volume = self.session.getVoiceEnableState() + is_turn_off_volume_val = not ( + ret_ves == 0 and is_turn_off_volume["isEnable"] == "1" + ) + + ret_les, is_turn_off_light = self.session.getLedEnableState() + is_turn_off_light_val = not ( + ret_les == 0 and is_turn_off_light["isEnable"] == "0" + ) + + is_open_wdr = None + is_open_hdr = None + reserve3 = product_info.get("reserve3") + reserve3_int = int(reserve3) if reserve3 is not None else 0 + + if (reserve3_int & (1 << 8)) != 0: + ret_wdr, is_open_wdr_data = self.session.getWdrMode() + mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 + is_open_wdr = bool(int(mode)) + else: + ret_hdr, is_open_hdr_data = self.session.getHdrMode() + mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 + is_open_hdr = bool(int(mode)) + + return FoscamDeviceInfo( + dev_info=dev_info, + product_info=product_info, + is_open_ir=is_open_ir, + is_flip=is_flip, + is_mirror=is_mirror, + is_asleep=is_asleep, + is_open_white_light=is_open_white_light_val, + is_siren_alarm=is_siren_alarm_val, + volume=volume_val, + speak_volume=speak_volume_val, + is_turn_off_volume=is_turn_off_volume_val, + is_turn_off_light=is_turn_off_light_val, + is_open_wdr=is_open_wdr, + is_open_hdr=is_open_hdr, + ) + + async def _async_update_data(self) -> FoscamDeviceInfo: """Fetch data from API endpoint.""" - - async with asyncio.timeout(30): - data = {} - ret, dev_info = await self.hass.async_add_executor_job( - self.session.get_dev_info - ) - if ret == 0: - data["dev_info"] = dev_info - - all_info = await self.hass.async_add_executor_job( - self.session.get_product_all_info - ) - data["product_info"] = all_info[1] - - ret, is_asleep = await self.hass.async_add_executor_job( - self.session.is_asleep - ) - data["is_asleep"] = {"supported": ret == 0, "status": is_asleep} - return data + async with asyncio.timeout(10): + return await self.hass.async_add_executor_job(self.gather_all_configs) diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py index e9d1bbbe176..7bc983cbfaa 100644 --- a/homeassistant/components/foscam/entity.py +++ b/homeassistant/components/foscam/entity.py @@ -13,19 +13,15 @@ from .coordinator import FoscamCoordinator class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): """Base entity for Foscam camera.""" - def __init__( - self, - coordinator: FoscamCoordinator, - entry_id: str, - ) -> None: + def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None: """Initialize the base Foscam entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry_id)}, + identifiers={(DOMAIN, config_entry_id)}, manufacturer="Foscam", ) - if dev_info := coordinator.data.get("dev_info"): + if dev_info := coordinator.data.dev_info: self._attr_device_info[ATTR_MODEL] = dev_info["productName"] self._attr_device_info[ATTR_SW_VERSION] = dev_info["firmwareVer"] self._attr_device_info[ATTR_HW_VERSION] = dev_info["hardwareVer"] diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json index 437575024d1..4b0b0c17c32 100644 --- a/homeassistant/components/foscam/icons.json +++ b/homeassistant/components/foscam/icons.json @@ -6,5 +6,39 @@ "ptz_preset": { "service": "mdi:target-variant" } + }, + "entity": { + "switch": { + "flip_switch": { + "default": "mdi:flip-vertical" + }, + "mirror_switch": { + "default": "mdi:mirror" + }, + "ir_switch": { + "default": "mdi:theme-light-dark" + }, + "sleep_switch": { + "default": "mdi:sleep" + }, + "white_light_switch": { + "default": "mdi:light-flood-down" + }, + "siren_alarm_switch": { + "default": "mdi:alarm-note" + }, + "turn_off_volume_switch": { + "default": "mdi:volume-off" + }, + "turn_off_light_switch": { + "default": "mdi:lightbulb-fluorescent-tube" + }, + "hdr_switch": { + "default": "mdi:hdr" + }, + "wdr_switch": { + "default": "mdi:alpha-w-box" + } + } } } diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 03351e3238f..29a74fdd2b4 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -27,8 +27,35 @@ }, "entity": { "switch": { + "flip_switch": { + "name": "Flip" + }, + "mirror_switch": { + "name": "Mirror" + }, + "ir_switch": { + "name": "Infrared mode" + }, "sleep_switch": { - "name": "Sleep" + "name": "Sleep mode" + }, + "white_light_switch": { + "name": "White light" + }, + "siren_alarm_switch": { + "name": "Siren alarm" + }, + "turn_off_volume_switch": { + "name": "Volume muted" + }, + "turn_off_light_switch": { + "name": "Light" + }, + "hdr_switch": { + "name": "HDR" + }, + "wdr_switch": { + "name": "WDR" } } }, diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 24b05b5aeaa..91118a27277 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -2,18 +2,117 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from libpyfoscamcgi import FoscamCamera + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity +def handle_ir_turn_on(session: FoscamCamera) -> None: + """Turn on IR LED: sets IR mode to auto (if supported), then turns off the IR LED.""" + + session.set_infra_led_config(1) + session.open_infra_led() + + +def handle_ir_turn_off(session: FoscamCamera) -> None: + """Turn off IR LED: sets IR mode to manual (if supported), then turns open the IR LED.""" + + session.set_infra_led_config(0) + session.close_infra_led() + + +@dataclass(frozen=True, kw_only=True) +class FoscamSwitchEntityDescription(SwitchEntityDescription): + """A custom entity description that supports a turn_off function.""" + + native_value_fn: Callable[..., bool] + turn_off_fn: Callable[[FoscamCamera], None] + turn_on_fn: Callable[[FoscamCamera], None] + + +SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [ + FoscamSwitchEntityDescription( + key="is_flip", + translation_key="flip_switch", + native_value_fn=lambda data: data.is_flip, + turn_off_fn=lambda session: session.flip_video(0), + turn_on_fn=lambda session: session.flip_video(1), + ), + FoscamSwitchEntityDescription( + key="is_mirror", + translation_key="mirror_switch", + native_value_fn=lambda data: data.is_mirror, + turn_off_fn=lambda session: session.mirror_video(0), + turn_on_fn=lambda session: session.mirror_video(1), + ), + FoscamSwitchEntityDescription( + key="is_open_ir", + translation_key="ir_switch", + native_value_fn=lambda data: data.is_open_ir, + turn_off_fn=handle_ir_turn_off, + turn_on_fn=handle_ir_turn_on, + ), + FoscamSwitchEntityDescription( + key="sleep_switch", + translation_key="sleep_switch", + native_value_fn=lambda data: data.is_asleep["status"], + turn_off_fn=lambda session: session.wake_up(), + turn_on_fn=lambda session: session.sleep(), + ), + FoscamSwitchEntityDescription( + key="is_open_white_light", + translation_key="white_light_switch", + native_value_fn=lambda data: data.is_open_white_light, + turn_off_fn=lambda session: session.closeWhiteLight(), + turn_on_fn=lambda session: session.openWhiteLight(), + ), + FoscamSwitchEntityDescription( + key="is_siren_alarm", + translation_key="siren_alarm_switch", + native_value_fn=lambda data: data.is_siren_alarm, + turn_off_fn=lambda session: session.setSirenConfig(0, 100, 0), + turn_on_fn=lambda session: session.setSirenConfig(1, 100, 0), + ), + FoscamSwitchEntityDescription( + key="is_turn_off_volume", + translation_key="turn_off_volume_switch", + native_value_fn=lambda data: data.is_turn_off_volume, + turn_off_fn=lambda session: session.setVoiceEnableState(1), + turn_on_fn=lambda session: session.setVoiceEnableState(0), + ), + FoscamSwitchEntityDescription( + key="is_turn_off_light", + translation_key="turn_off_light_switch", + native_value_fn=lambda data: data.is_turn_off_light, + turn_off_fn=lambda session: session.setLedEnableState(0), + turn_on_fn=lambda session: session.setLedEnableState(1), + ), + FoscamSwitchEntityDescription( + key="is_open_hdr", + translation_key="hdr_switch", + native_value_fn=lambda data: data.is_open_hdr, + turn_off_fn=lambda session: session.setHdrMode(0), + turn_on_fn=lambda session: session.setHdrMode(1), + ), + FoscamSwitchEntityDescription( + key="is_open_wdr", + translation_key="wdr_switch", + native_value_fn=lambda data: data.is_open_wdr, + turn_off_fn=lambda session: session.setWdrMode(0), + turn_on_fn=lambda session: session.setWdrMode(1), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: FoscamConfigEntry, @@ -22,63 +121,61 @@ async def async_setup_entry( """Set up foscam switch from a config entry.""" coordinator = config_entry.runtime_data - await coordinator.async_config_entry_first_refresh() - if coordinator.data["is_asleep"]["supported"]: - async_add_entities([FoscamSleepSwitch(coordinator, config_entry)]) + entities = [] + + product_info = coordinator.data.product_info + reserve3 = product_info.get("reserve3", "0") + + for description in SWITCH_DESCRIPTIONS: + if description.key == "is_asleep": + if not coordinator.data.is_asleep["supported"]: + continue + elif description.key == "is_open_hdr": + if ((1 << 8) & int(reserve3)) != 0 or ((1 << 7) & int(reserve3)) == 0: + continue + elif description.key == "is_open_wdr": + if ((1 << 8) & int(reserve3)) == 0: + continue + + entities.append(FoscamGenericSwitch(coordinator, description)) + async_add_entities(entities) -class FoscamSleepSwitch(FoscamEntity, SwitchEntity): - """An implementation for Sleep Switch.""" +class FoscamGenericSwitch(FoscamEntity, SwitchEntity): + """A generic switch class for Foscam entities.""" + + _attr_has_entity_name = True + entity_description: FoscamSwitchEntityDescription def __init__( self, coordinator: FoscamCoordinator, - config_entry: FoscamConfigEntry, + description: FoscamSwitchEntityDescription, ) -> None: - """Initialize a Foscam Sleep Switch.""" - super().__init__(coordinator, config_entry.entry_id) + """Initialize the generic switch.""" + entry_id = coordinator.config_entry.entry_id + super().__init__(coordinator, entry_id) - self._attr_unique_id = f"{config_entry.entry_id}_sleep_switch" - self._attr_translation_key = "sleep_switch" - self._attr_has_entity_name = True - - self.is_asleep = self.coordinator.data["is_asleep"]["status"] + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" @property - def is_on(self): - """Return true if camera is asleep.""" - return self.is_asleep + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.native_value_fn(self.coordinator.data) async def async_turn_off(self, **kwargs: Any) -> None: - """Wake camera.""" - LOGGER.debug("Wake camera") - - ret, _ = await self.hass.async_add_executor_job( - self.coordinator.session.wake_up + """Turn off the entity.""" + self.hass.async_add_executor_job( + self.entity_description.turn_off_fn, self.coordinator.session ) - - if ret != 0: - raise HomeAssistantError(f"Error waking up: {ret}") - await self.coordinator.async_request_refresh() async def async_turn_on(self, **kwargs: Any) -> None: - """But camera is sleep.""" - LOGGER.debug("Sleep camera") - - ret, _ = await self.hass.async_add_executor_job(self.coordinator.session.sleep) - - if ret != 0: - raise HomeAssistantError(f"Error sleeping: {ret}") - + """Turn on the entity.""" + self.hass.async_add_executor_job( + self.entity_description.turn_on_fn, self.coordinator.session + ) await self.coordinator.async_request_refresh() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - - self.is_asleep = self.coordinator.data["is_asleep"]["status"] - - self.async_write_ha_state() diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index f8b4093574f..43616693303 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -60,6 +60,21 @@ def setup_mock_foscam_camera(mock_foscam_camera): mock_foscam_camera.get_dev_info.return_value = (dev_info_rc, dev_info_data) mock_foscam_camera.get_port_info.return_value = (dev_info_rc, {}) mock_foscam_camera.is_asleep.return_value = (0, True) + mock_foscam_camera.get_infra_led_config.return_value = (0, {"mode": "1"}) + mock_foscam_camera.get_mirror_and_flip_setting.return_value = ( + 0, + {"isFlip": "0", "isMirror": "0"}, + ) + mock_foscam_camera.is_asleep.return_value = (0, "0") + mock_foscam_camera.getWhiteLightBrightness.return_value = (0, {"enable": "1"}) + mock_foscam_camera.getSirenConfig.return_value = (0, {"sirenEnable": "1"}) + mock_foscam_camera.getAudioVolume.return_value = (0, {"volume": "100"}) + mock_foscam_camera.getSpeakVolume.return_value = (0, {"SpeakVolume": "100"}) + mock_foscam_camera.getVoiceEnableState.return_value = (0, {"isEnable": "1"}) + mock_foscam_camera.getLedEnableState.return_value = (0, {"isEnable": "0"}) + mock_foscam_camera.getWdrMode.return_value = (0, {"mode": "0"}) + mock_foscam_camera.getHdrMode.return_value = (0, {"mode": "0"}) + mock_foscam_camera.get_motion_detect_config.return_value = (0, 1) return mock_foscam_camera diff --git a/tests/components/foscam/snapshots/test_switch.ambr b/tests/components/foscam/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f48df6b65e6 --- /dev/null +++ b/tests/components/foscam/snapshots/test_switch.ambr @@ -0,0 +1,385 @@ +# serializer version: 1 +# name: test_entities[switch.mock_title_flip-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.mock_title_flip', + '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': 'Flip', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flip_switch', + 'unique_id': '123ABC_is_flip', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_flip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Flip', + }), + 'context': , + 'entity_id': 'switch.mock_title_flip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_infrared_mode-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.mock_title_infrared_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': 'Infrared mode', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_switch', + 'unique_id': '123ABC_is_open_ir', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_infrared_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Infrared mode', + }), + 'context': , + 'entity_id': 'switch.mock_title_infrared_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[switch.mock_title_light-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.mock_title_light', + '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': 'Light', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_off_light_switch', + 'unique_id': '123ABC_is_turn_off_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Light', + }), + 'context': , + 'entity_id': 'switch.mock_title_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_mirror-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.mock_title_mirror', + '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': 'Mirror', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mirror_switch', + 'unique_id': '123ABC_is_mirror', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_mirror-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Mirror', + }), + 'context': , + 'entity_id': 'switch.mock_title_mirror', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_siren_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_siren_alarm', + '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': 'Siren alarm', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_alarm_switch', + 'unique_id': '123ABC_is_siren_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_siren_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Siren alarm', + }), + 'context': , + 'entity_id': 'switch.mock_title_siren_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[switch.mock_title_sleep_mode-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.mock_title_sleep_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': 'Sleep mode', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sleep_switch', + 'unique_id': '123ABC_sleep_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_sleep_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sleep mode', + }), + 'context': , + 'entity_id': 'switch.mock_title_sleep_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_volume_muted-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.mock_title_volume_muted', + '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 muted', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_off_volume_switch', + 'unique_id': '123ABC_is_turn_off_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_volume_muted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Volume muted', + }), + 'context': , + 'entity_id': 'switch.mock_title_volume_muted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[switch.mock_title_white_light-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.mock_title_white_light', + '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': 'White light', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'white_light_switch', + 'unique_id': '123ABC_is_open_white_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch.mock_title_white_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title White light', + }), + 'context': , + 'entity_id': 'switch.mock_title_white_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py index a7b6a8c8f0b..7c7b1b8aee8 100644 --- a/tests/components/foscam/test_init.py +++ b/tests/components/foscam/test_init.py @@ -96,7 +96,7 @@ async def test_unique_id_migration_not_needed( assert entity_before.unique_id == f"{ENTRY_ID}_sleep_switch" with ( - # Mock a valid camera instance" + # Mock a valid camera instance patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, patch( "homeassistant.components.foscam.async_migrate_entry", diff --git a/tests/components/foscam/test_switch.py b/tests/components/foscam/test_switch.py new file mode 100644 index 00000000000..bd9eb380fbd --- /dev/null +++ b/tests/components/foscam/test_switch.py @@ -0,0 +1,35 @@ +"""Test for the switch platform entity of the foscam component.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.foscam.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance" + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch("homeassistant.components.foscam.PLATFORMS", [Platform.SWITCH]), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From 3b358df9e783124b25fcefc4e84e055cd5540b64 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 11 Aug 2025 23:22:13 +0300 Subject: [PATCH 1114/1118] Jewish Calendar add coordinator (#141456) Co-authored-by: Joost Lekkerkerker --- .../components/jewish_calendar/__init__.py | 18 ++- .../jewish_calendar/binary_sensor.py | 3 +- .../components/jewish_calendar/coordinator.py | 116 ++++++++++++++ .../components/jewish_calendar/diagnostics.py | 2 +- .../components/jewish_calendar/entity.py | 64 +------- .../components/jewish_calendar/sensor.py | 38 ++--- .../snapshots/test_diagnostics.ambr | 150 +++++++++--------- 7 files changed, 227 insertions(+), 164 deletions(-) create mode 100644 homeassistant/components/jewish_calendar/coordinator.py diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 8e01b6b6ae0..0f5a066600c 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -29,7 +29,8 @@ from .const import ( DEFAULT_LANGUAGE, DOMAIN, ) -from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator +from .entity import JewishCalendarConfigEntry from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ async def async_setup_entry( ) ) - config_entry.runtime_data = JewishCalendarData( + data = JewishCalendarData( language, diaspora, location, @@ -77,8 +78,11 @@ async def async_setup_entry( havdalah_offset, ) - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -86,7 +90,13 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator = config_entry.runtime_data + if coordinator.event_unsub: + coordinator.event_unsub() + return unload_ok async def async_migrate_entry( diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index d5097df962f..205691bc183 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -72,8 +72,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim)(dt_util.now()) + return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now()) def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: """Return a list of times to update the sensor.""" diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py new file mode 100644 index 00000000000..21713313043 --- /dev/null +++ b/homeassistant/components/jewish_calendar/coordinator.py @@ -0,0 +1,116 @@ +"""Data update coordinator for Jewish calendar.""" + +from dataclasses import dataclass +import datetime as dt +import logging + +from hdate import HDateInfo, Location, Zmanim +from hdate.translator import Language, set_language + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import event +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator] + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: Language + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int + dateinfo: HDateInfo | None = None + zmanim: Zmanim | None = None + + +class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]): + """Data update coordinator class for Jewish calendar.""" + + config_entry: JewishCalendarConfigEntry + event_unsub: CALLBACK_TYPE | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: JewishCalendarConfigEntry, + data: JewishCalendarData, + ) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry) + self.data = data + self._unsub_update: CALLBACK_TYPE | None = None + set_language(data.language) + + async def _async_update_data(self) -> JewishCalendarData: + """Return HDate and Zmanim for today.""" + now = dt_util.now() + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + + self.data.dateinfo = HDateInfo(today, self.data.diaspora) + self.data.zmanim = self.make_zmanim(today) + self.async_schedule_future_update() + return self.data + + @callback + def async_schedule_future_update(self) -> None: + """Schedule the next update of the sensor for the upcoming midnight.""" + # Cancel any existing update + if self._unsub_update: + self._unsub_update() + self._unsub_update = None + + # Calculate the next midnight + next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) + + _LOGGER.debug("Scheduling next update at %s", next_midnight) + + # Schedule update at next midnight + self._unsub_update = event.async_track_point_in_time( + self.hass, self._handle_midnight_update, next_midnight + ) + + @callback + def _handle_midnight_update(self, _now: dt.datetime) -> None: + """Handle midnight update callback.""" + self._unsub_update = None + self.async_set_updated_data(self.data) + + async def async_shutdown(self) -> None: + """Cancel any scheduled updates when the coordinator is shutting down.""" + await super().async_shutdown() + if self._unsub_update: + self._unsub_update() + self._unsub_update = None + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) + + @property + def zmanim(self) -> Zmanim: + """Return the current Zmanim.""" + assert self.data.zmanim is not None, "Zmanim data not available" + return self.data.zmanim + + @property + def dateinfo(self) -> HDateInfo: + """Return the current HDateInfo.""" + assert self.data.dateinfo is not None, "HDateInfo data not available" + return self.data.dateinfo diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py index 27415282b6d..f2db0786b12 100644 --- a/homeassistant/components/jewish_calendar/diagnostics.py +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT), } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index d5e41129075..d3007212739 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,48 +1,22 @@ """Entity representing a Jewish Calendar sensor.""" from abc import abstractmethod -from dataclasses import dataclass import datetime as dt -import logging -from hdate import HDateInfo, Location, Zmanim -from hdate.translator import Language, set_language +from hdate import Zmanim -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] +from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator -@dataclass -class JewishCalendarDataResults: - """Jewish Calendar results dataclass.""" - - dateinfo: HDateInfo - zmanim: Zmanim - - -@dataclass -class JewishCalendarData: - """Jewish Calendar runtime dataclass.""" - - language: Language - diaspora: bool - location: Location - candle_lighting_offset: int - havdalah_offset: int - results: JewishCalendarDataResults | None = None - - -class JewishCalendarEntity(Entity): +class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True @@ -55,23 +29,13 @@ class JewishCalendarEntity(Entity): description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" + super().__init__(config_entry.runtime_data) self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) - self.data = config_entry.runtime_data - set_language(self.data.language) - - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self.data.location, - candle_lighting_offset=self.data.candle_lighting_offset, - havdalah_offset=self.data.havdalah_offset, - ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -92,10 +56,9 @@ class JewishCalendarEntity(Entity): def _schedule_update(self) -> None: """Schedule the next update of the sensor.""" now = dt_util.now() - zmanim = self.make_zmanim(now.date()) update = dt_util.start_of_local_day() + dt.timedelta(days=1) - for update_time in self._update_times(zmanim): + for update_time in self._update_times(self.coordinator.zmanim): if update_time is not None and now < update_time < update: update = update_time @@ -110,17 +73,4 @@ class JewishCalendarEntity(Entity): """Update the sensor data.""" self._update_unsub = None self._schedule_update() - self.create_results(now) self.async_write_ha_state() - - def create_results(self, now: dt.datetime | None = None) -> None: - """Create the results for the sensor.""" - if now is None: - now = dt_util.now() - - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - zmanim = self.make_zmanim(today) - dateinfo = HDateInfo(today, diaspora=self.data.diaspora) - self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d9ad89237f5..579c8e0f6a6 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @@ -236,25 +236,18 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): return [] return [self.entity_description.next_update_fn(zmanim)] - def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: + def get_dateinfo(self) -> HDateInfo: """Get the next date info.""" - if self.data.results is None: - self.create_results() - assert self.data.results is not None, "Results should be available" - - if now is None: - now = dt_util.now() - - today = now.date() - zmanim = self.make_zmanim(today) + now = dt_util.now() update = None - if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(zmanim) - _LOGGER.debug("Today: %s, update: %s", today, update) + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(self.coordinator.zmanim) + + _LOGGER.debug("Today: %s, update: %s", now.date(), update) if update is not None and now >= update: - return self.data.results.dateinfo.next_day - return self.data.results.dateinfo + return self.coordinator.dateinfo.next_day + return self.coordinator.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -271,7 +264,9 @@ class JewishCalendarSensor(JewishCalendarBaseSensor): super().__init__(config_entry, description) # Set the options for enumeration sensors if self.entity_description.options_fn is not None: - self._attr_options = self.entity_description.options_fn(self.data.diaspora) + self._attr_options = self.entity_description.options_fn( + self.coordinator.data.diaspora + ) @property def native_value(self) -> str | int | dt.datetime | None: @@ -295,9 +290,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @property def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - self.create_results() - assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: - return self.data.results.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) + return self.coordinator.zmanim.zmanim[self.entity_description.key].local + return self.entity_description.value_fn( + self.get_dateinfo(), self.coordinator.make_zmanim + ) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 0a392e101c5..859cdefd9c2 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,6 +3,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -17,33 +26,22 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), }), @@ -59,6 +57,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': True, + 'nusach': 'sephardi', + }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -73,33 +80,22 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), }), @@ -115,6 +111,15 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), + 'diaspora': False, + 'nusach': 'sephardi', + }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -129,33 +134,22 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'results': dict({ - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', - 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", - }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), }), From e13702d9b1530a7615b06e5a3a5c92ef1b865b3c Mon Sep 17 00:00:00 2001 From: Kevin David Date: Mon, 11 Aug 2025 16:25:41 -0400 Subject: [PATCH 1115/1118] Bump python-snoo to 0.7.0 (#150434) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 2afec990e4b..b47947ab0e0 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.6"] + "requirements": ["python-snoo==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 12d143f452b..5c3747f8658 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.6.6 +python-snoo==0.7.0 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe5a7412bcb..7fbf37d1df6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.6.6 +python-snoo==0.7.0 # homeassistant.components.songpal python-songpal==0.16.2 From 9cae0e0acc7b65fb973f5c7ed2574454aced2227 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 11 Aug 2025 23:28:36 +0300 Subject: [PATCH 1116/1118] OpenAI thinking content (#150340) --- .../components/openai_conversation/entity.py | 175 +++++++++++------- .../openai_conversation/__init__.py | 80 +++++++- .../snapshots/test_conversation.ambr | 67 +++++++ .../openai_conversation/test_conversation.py | 14 +- 4 files changed, 259 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 748c0c8f874..9c1e77be7d3 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -3,11 +3,11 @@ from __future__ import annotations import base64 -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterable import json from mimetypes import guess_file_type from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal import openai from openai._streaming import AsyncStream @@ -29,14 +29,15 @@ from openai.types.responses import ( ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, ResponseOutputMessage, - ResponseOutputMessageParam, ResponseReasoningItem, ResponseReasoningItemParam, + ResponseReasoningSummaryTextDeltaEvent, ResponseStreamEvent, ResponseTextDeltaEvent, ToolParam, WebSearchToolParam, ) +from openai.types.responses.response_create_params import ResponseCreateParamsStreaming from openai.types.responses.response_input_param import FunctionCallOutput from openai.types.responses.tool_param import ( CodeInterpreter, @@ -143,70 +144,116 @@ def _format_tool( def _convert_content_to_param( - content: conversation.Content, + chat_content: Iterable[conversation.Content], ) -> ResponseInputParam: """Convert any native chat message for this agent to the native format.""" messages: ResponseInputParam = [] - if isinstance(content, conversation.ToolResultContent): - return [ - FunctionCallOutput( - type="function_call_output", - call_id=content.tool_call_id, - output=json.dumps(content.tool_result), - ) - ] + reasoning_summary: list[str] = [] - if content.content: - role: Literal["user", "assistant", "system", "developer"] = content.role - if role == "system": - role = "developer" - messages.append( - EasyInputMessageParam(type="message", role=role, content=content.content) - ) - - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - messages.extend( - ResponseFunctionToolCallParam( - type="function_call", - name=tool_call.tool_name, - arguments=json.dumps(tool_call.tool_args), - call_id=tool_call.id, + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + messages.append( + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) ) - for tool_call in content.tool_calls - ) + continue + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role + if role == "system": + role = "developer" + messages.append( + EasyInputMessageParam( + type="message", role=role, content=content.content + ) + ) + + if isinstance(content, conversation.AssistantContent): + if content.tool_calls: + messages.extend( + ResponseFunctionToolCallParam( + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, + ) + for tool_call in content.tool_calls + ) + + if content.thinking_content: + reasoning_summary.append(content.thinking_content) + + if isinstance(content.native, ResponseReasoningItem): + messages.append( + ResponseReasoningItemParam( + type="reasoning", + id=content.native.id, + summary=[ + { + "type": "summary_text", + "text": summary, + } + for summary in reasoning_summary + ] + if content.thinking_content + else [], + encrypted_content=content.native.encrypted_content, + ) + ) + reasoning_summary = [] + return messages async def _transform_stream( chat_log: conversation.ChatLog, - result: AsyncStream[ResponseStreamEvent], - messages: ResponseInputParam, + stream: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" - async for event in result: + last_summary_index = None + + async for event in stream: LOGGER.debug("Received event: %s", event) if isinstance(event, ResponseOutputItemAddedEvent): if isinstance(event.item, ResponseOutputMessage): yield {"role": event.item.role} + last_summary_index = None elif isinstance(event.item, ResponseFunctionToolCall): # OpenAI has tool calls as individual events # while HA puts tool calls inside the assistant message. # We turn them into individual assistant content for HA # to ensure that tools are called as soon as possible. yield {"role": "assistant"} + last_summary_index = None current_tool_call = event.item elif isinstance(event, ResponseOutputItemDoneEvent): - item = event.item.model_dump() - item.pop("status", None) if isinstance(event.item, ResponseReasoningItem): - messages.append(cast(ResponseReasoningItemParam, item)) - elif isinstance(event.item, ResponseOutputMessage): - messages.append(cast(ResponseOutputMessageParam, item)) - elif isinstance(event.item, ResponseFunctionToolCall): - messages.append(cast(ResponseFunctionToolCallParam, item)) + yield { + "native": ResponseReasoningItem( + type="reasoning", + id=event.item.id, + summary=[], # Remove summaries + encrypted_content=event.item.encrypted_content, + ) + } elif isinstance(event, ResponseTextDeltaEvent): yield {"content": event.delta} + elif isinstance(event, ResponseReasoningSummaryTextDeltaEvent): + # OpenAI can output several reasoning summaries + # in a single ResponseReasoningItem. We split them as separate + # AssistantContent messages. Only last of them will have + # the reasoning `native` field set. + if ( + last_summary_index is not None + and event.summary_index != last_summary_index + ): + yield {"role": "assistant"} + last_summary_index = event.summary_index + yield {"thinking_content": event.delta} elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): current_tool_call.arguments += event.delta elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): @@ -335,16 +382,18 @@ class OpenAIBaseLLMEntity(Entity): ) ) - model_args = { - "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - "input": [], - "max_output_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "store": False, - "stream": True, - } + messages = _convert_content_to_param(chat_log.content) + + model_args = ResponseCreateParamsStreaming( + model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + input=messages, + max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + user=chat_log.conversation_id, + store=False, + stream=True, + ) if tools: model_args["tools"] = tools @@ -352,7 +401,8 @@ class OpenAIBaseLLMEntity(Entity): model_args["reasoning"] = { "effort": options.get( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) + ), + "summary": "auto", } model_args["include"] = ["reasoning.encrypted_content"] @@ -361,12 +411,6 @@ class OpenAIBaseLLMEntity(Entity): "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) } - messages = [ - m - for content in chat_log.content - for m in _convert_content_to_param(content) - ] - last_content = chat_log.content[-1] # Handle attachments by adding them to the last user message @@ -399,16 +443,19 @@ class OpenAIBaseLLMEntity(Entity): # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args["input"] = messages - try: - result = await client.responses.create(**model_args) + stream = await client.responses.create(**model_args) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) + messages.extend( + _convert_content_to_param( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, stream) + ) + ] + ) + ) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index 0cdccb6d0cf..0ca02b8f629 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -18,6 +18,10 @@ from openai.types.responses import ( ResponseOutputMessage, ResponseOutputText, ResponseReasoningItem, + ResponseReasoningSummaryPartAddedEvent, + ResponseReasoningSummaryPartDoneEvent, + ResponseReasoningSummaryTextDeltaEvent, + ResponseReasoningSummaryTextDoneEvent, ResponseStreamEvent, ResponseTextDeltaEvent, ResponseTextDoneEvent, @@ -26,6 +30,7 @@ from openai.types.responses import ( ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response_function_web_search import ActionSearch +from openai.types.responses.response_reasoning_item import Summary def create_message_item( @@ -173,9 +178,23 @@ def create_function_tool_call_item( return events -def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: +def create_reasoning_item( + id: str, + output_index: int, + reasoning_summary: list[list[str]] | list[str] | str | None = None, +) -> list[ResponseStreamEvent]: """Create a reasoning item.""" - return [ + + if reasoning_summary is None: + reasoning_summary = [[]] + elif isinstance(reasoning_summary, str): + reasoning_summary = [reasoning_summary] + if isinstance(reasoning_summary, list) and all( + isinstance(item, str) for item in reasoning_summary + ): + reasoning_summary = [reasoning_summary] + + events = [ ResponseOutputItemAddedEvent( item=ResponseReasoningItem( id=id, @@ -187,11 +206,60 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven output_index=output_index, sequence_number=0, type="response.output_item.added", - ), + ) + ] + + for summary_index, summary in enumerate(reasoning_summary): + events.append( + ResponseReasoningSummaryPartAddedEvent( + item_id=id, + output_index=output_index, + part={"text": "", "type": "summary_text"}, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_part.added", + ) + ) + events.extend( + ResponseReasoningSummaryTextDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_text.delta", + ) + for delta in summary + ) + events.extend( + [ + ResponseReasoningSummaryTextDoneEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + summary_index=summary_index, + text="".join(summary), + type="response.reasoning_summary_text.done", + ), + ResponseReasoningSummaryPartDoneEvent( + item_id=id, + output_index=output_index, + part={"text": "".join(summary), "type": "summary_text"}, + sequence_number=0, + summary_index=summary_index, + type="response.reasoning_summary_part.done", + ), + ] + ) + + events.append( ResponseOutputItemDoneEvent( item=ResponseReasoningItem( id=id, - summary=[], + summary=[ + Summary(text="".join(summary), type="summary_text") + for summary in reasoning_summary + ], type="reasoning", status=None, encrypted_content="AAABBB", @@ -200,7 +268,9 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven sequence_number=0, type="response.output_item.done", ), - ] + ) + + return events def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 93b86bd4bc1..7a03c484182 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -6,6 +6,22 @@ 'content': 'Please call the test function', 'role': 'user', }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': None, + 'native': None, + 'role': 'assistant', + 'thinking_content': 'Thinking', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': None, + 'native': ResponseReasoningItem(id='rs_A', summary=[], type='reasoning', content=None, encrypted_content='AAABBB', status=None), + 'role': 'assistant', + 'thinking_content': 'Thinking more', + 'tool_calls': None, + }), dict({ 'agent_id': 'conversation.openai_conversation', 'content': None, @@ -62,6 +78,57 @@ }), ]) # --- +# name: test_function_call.1 + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'encrypted_content': 'AAABBB', + 'id': 'rs_A', + 'summary': list([ + dict({ + 'text': 'Thinking', + 'type': 'summary_text', + }), + dict({ + 'text': 'Thinking more', + 'type': 'summary_text', + }), + ]), + 'type': 'reasoning', + }), + dict({ + 'arguments': '{"param1": "call1"}', + 'call_id': 'call_call_1', + 'name': 'test_tool', + 'type': 'function_call', + }), + dict({ + 'call_id': 'call_call_1', + 'output': '"value1"', + 'type': 'function_call_output', + }), + dict({ + 'arguments': '{"param1": "call2"}', + 'call_id': 'call_call_2', + 'name': 'test_tool', + 'type': 'function_call', + }), + dict({ + 'call_id': 'call_call_2', + 'output': '"value2"', + 'type': 'function_call_output', + }), + dict({ + 'content': 'Cool', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- # name: test_function_call_without_reasoning list([ dict({ diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 5abce689855..921eb39c542 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -252,7 +252,11 @@ async def test_function_call( # Initial conversation ( # Wait for the model to think - *create_reasoning_item(id="rs_A", output_index=0), + *create_reasoning_item( + id="rs_A", + output_index=0, + reasoning_summary=[["Thinking"], ["Thinking ", "more"]], + ), # First tool call *create_function_tool_call_item( id="fc_1", @@ -288,16 +292,10 @@ async def test_function_call( agent_id="conversation.openai_conversation", ) - assert mock_create_stream.call_args.kwargs["input"][2] == { - "content": None, - "id": "rs_A", - "summary": [], - "type": "reasoning", - "encrypted_content": "AAABBB", - } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic assert mock_chat_log.content[1:] == snapshot + assert mock_create_stream.call_args.kwargs["input"][1:] == snapshot async def test_function_call_without_reasoning( From 715dc12792a9cf53e9af2cf72ade8badb89072e0 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:40:40 -0400 Subject: [PATCH 1117/1118] Add media browsing to Russound RIO (#148248) --- .../components/russound_rio/media_browser.py | 97 +++++++++++++++++++ .../components/russound_rio/media_player.py | 16 ++- .../russound_rio/fixtures/get_zones.json | 3 +- .../snapshots/test_media_browser.ambr | 75 ++++++++++++++ .../russound_rio/test_media_browser.py | 61 ++++++++++++ 5 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/russound_rio/media_browser.py create mode 100644 tests/components/russound_rio/snapshots/test_media_browser.ambr create mode 100644 tests/components/russound_rio/test_media_browser.py diff --git a/homeassistant/components/russound_rio/media_browser.py b/homeassistant/components/russound_rio/media_browser.py new file mode 100644 index 00000000000..7e5ca741f90 --- /dev/null +++ b/homeassistant/components/russound_rio/media_browser.py @@ -0,0 +1,97 @@ +"""Support for Russound media browsing.""" + +from aiorussound import RussoundClient, Zone +from aiorussound.const import FeatureFlag +from aiorussound.util import is_feature_supported + +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.core import HomeAssistant + + +async def async_browse_media( + hass: HomeAssistant, + client: RussoundClient, + media_content_id: str | None, + media_content_type: str | None, + zone: Zone, +) -> BrowseMedia: + """Browse media.""" + if media_content_type == "presets": + return await _presets_payload(_find_presets_by_zone(client, zone)) + + return await _root_payload(hass, _find_presets_by_zone(client, zone)) + + +async def _root_payload( + hass: HomeAssistant, presets_by_zone: dict[int, dict[int, str]] +) -> BrowseMedia: + """Return root payload for Russound RIO.""" + children: list[BrowseMedia] = [] + + if presets_by_zone: + children.append( + BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + thumbnail="https://brands.home-assistant.io/_/russound_rio/logo.png", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMedia( + title="Russound", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="root", + can_play=False, + can_expand=True, + children=children, + ) + + +async def _presets_payload(presets_by_zone: dict[int, dict[int, str]]) -> BrowseMedia: + """Create payload to list presets.""" + children: list[BrowseMedia] = [] + for source_id, presets in presets_by_zone.items(): + for preset_id, preset_name in presets.items(): + children.append( + BrowseMedia( + title=preset_name, + media_class=MediaClass.CHANNEL, + media_content_id=f"{source_id},{preset_id}", + media_content_type="preset", + can_play=True, + can_expand=False, + ) + ) + + return BrowseMedia( + title="Presets", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type="presets", + can_play=False, + can_expand=True, + children=children, + ) + + +def _find_presets_by_zone( + client: RussoundClient, zone: Zone +) -> dict[int, dict[int, str]]: + """Returns a dict by {source_id: {preset_id: preset_name}}.""" + assert client.rio_version + return { + source_id: source.presets + for source_id, source in client.sources.items() + if source.presets + and ( + not is_feature_supported( + client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION + ) + or source_id in zone.enabled_sources + ) + } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index a4b86a85e94..a09c663a983 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -13,6 +13,7 @@ from aiorussound.models import PlayStatus, Source from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( + BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import RussoundConfigEntry +from . import RussoundConfigEntry, media_browser from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY from .entity import RussoundBaseEntity, command @@ -65,7 +66,8 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_media_content_type = MediaType.MUSIC _attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_SET + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.TURN_ON @@ -264,3 +266,13 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): translation_placeholders={"preset_id": media_id}, ) await self._zone.restore_preset(preset_id) + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the media browsing helper.""" + return await media_browser.async_browse_media( + self.hass, self._client, media_content_id, media_content_type, self._zone + ) diff --git a/tests/components/russound_rio/fixtures/get_zones.json b/tests/components/russound_rio/fixtures/get_zones.json index e1077944593..b51c93875f1 100644 --- a/tests/components/russound_rio/fixtures/get_zones.json +++ b/tests/components/russound_rio/fixtures/get_zones.json @@ -7,7 +7,8 @@ "volume": "10", "status": "ON", "enabled": "True", - "current_source": "1" + "current_source": "1", + "enabled_sources": [1, 2] }, "2": { "name": "Kitchen", diff --git a/tests/components/russound_rio/snapshots/test_media_browser.ambr b/tests/components/russound_rio/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..7c3df31a69b --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_media_browser.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'presets', + 'thumbnail': 'https://brands.home-assistant.io/_/russound_rio/logo.png', + 'title': 'Presets', + }), + ]) +# --- +# name: test_browse_presets + list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,1', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WOOD', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,2', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': '89.7 MHz FM', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,7', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WWKR', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,8', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WKLA', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'channel', + 'media_content_id': '1,11', + 'media_content_type': 'preset', + 'thumbnail': None, + 'title': 'WGN', + }), + ]) +# --- diff --git a/tests/components/russound_rio/test_media_browser.py b/tests/components/russound_rio/test_media_browser.py new file mode 100644 index 00000000000..d2d67e70aeb --- /dev/null +++ b/tests/components/russound_rio/test_media_browser.py @@ -0,0 +1,61 @@ +"""Tests for the Russound RIO media browser.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID_ZONE_1 + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def test_browse_media_root( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the root browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID_ZONE_1, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_presets( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the presets browse page.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": ENTITY_ID_ZONE_1, + "media_content_type": "presets", + "media_content_id": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot From 6e3ccbefc2706c6f80d47ef9e6dbab1192e3644b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:50:47 -0400 Subject: [PATCH 1118/1118] Add quality scale for Sonos (#144928) --- .../components/sonos/quality_scale.yaml | 76 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sonos/quality_scale.yaml diff --git a/homeassistant/components/sonos/quality_scale.yaml b/homeassistant/components/sonos/quality_scale.yaml new file mode 100644 index 00000000000..5899503ae8d --- /dev/null +++ b/homeassistant/components/sonos/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-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: + status: exempt + comment: | + Setup is done through discovery and does not require a test before setup. + unique-config-entry: + status: exempt + comment: | + Integration only supports and uses a single config entry. Exempting because hassfest check is incomplete. + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + No authentication + test-coverage: + status: todo + comment: | + test_play_media_library if statements in the tests + PR #147064 + test_sensor is testing both binary sensor and sensor + tests using internals + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: | + No configurable options + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 318b9d3f7cb..97d510ce4ca 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -922,7 +922,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "somfy_mylink", "sonarr", "songpal", - "sonos", "sony_projector", "soundtouch", "spaceapi",